The setup
At Crystal Peak, we do detail-oriented client work by hand. But in our spare time, we throw side projects at AI - stuff we wouldn't have bandwidth for otherwise. One of those: jcc.
jcc, a compiler for JavaCard bytecode, lets you write performant JavaCard applets in C (and soon, rust!). Keep an eye out - we'll write up how we ported DOOM to your credit card.
I was in the middle of a major refactor, translating jcc's direct compilation model to one that takes in LLVM IR.
I knew this would be an undertaking, but had no idea just how complex things can get with LLVM. This rewrite took over 3x as long as the original implementation. But things were going well, I had just crossed off the last milestone: adding a peephole optimization pass at the end of the pipeline. The finish line was in sight.
The moment
I left the agent running in the background while I took care of a few things. I check back and see it asking for permission to run this command:
rm -rf examples/minimal/build/*.cap examples/minimal/build/*.jca 2>/dev/null
uv run python -m backend3 examples/minimal --jcc-root . 2>&1 | tail -3
Harmless, right? No, really - look at it. Tell me. It's harmless, right?
I approved it.
I come back to a flurry of errors. Path does not exist: examples/minimal
I run ls. Nothing is there. Not even the folder for the project. Just silence filling the space hours of work used to occupy.
The recovery
It's a strange feeling, having many hours of work disappear. Normally I'd just git checkout to get back to a clean state... but the whole folder here was deleted, .git included. This being a messy side project, I hadn't pushed it anywhere. It was truly gone.
Except: I remembered from an earlier spelunking session that Claude Code keeps all conversation history in ~/.claude. Maybe I could use that to recover the plan files I had created, at least? A small faint light emerges.
A journey through ~/.claude
Claude keeps a lot of files around in this folder. On my machine, it's many gigabytes of data. By default, most things in here are deleted after 30 days.
Taking a look in the directory:
cache/ - Unsure, only contains changelog.md
debug/ - Claude internal debug logs
file-history/ - Snapshots of files (!!!)
history.jsonl - Used for ctrl+r in the Claude prompt
ide/ - IDE integration...?
paste-cache/ - Everything you've pasted
plans/ - All plan files
plugins/ - Plugin system machinery
projects/ - All of your projects, keyed by directory name
session-env/ - Unsure
settings.json - Basic global settings
shell-snapshots/ - Bootstrap shell scripts...?
stats-cache.json - Historical model usage stats
statsig/ - A/B testing, feature flags, and user analysis
tasks/ - Tasks - newer form of TodoList?
telemetry/ - Unsure exactly what telemetry this is
todos/ - TodoList entries
plans/ is right there, so getting that data back should be easy. And file-history/ is what powers the "rewind" feature when you roll back a conversation - my data should be in there! The files have random names, but can be correlated back to their original paths using the conversation logs. The conversation log is stored as a .jsonl file - each line is a json entry. The structure is beyond the scope of this article, but for reference, here's a snapshot line, pretty-printed:
{
"type": "file-history-snapshot",
"messageId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"snapshot": {
"messageId": "12345678-abcd-4def-9876-fedcba987654",
"trackedFileBackups": {
"src/claude-decoder/models.py": {
"backupFileName": "ab12cd34ef56gh78@v1",
"version": 1,
"backupTime": "2025-06-15T14:30:22.041Z"
}
},
"timestamp": "2025-06-15T14:29:58.123Z"
},
"isSnapshotUpdate": true
}
My snapshots were incomplete - they didn't have all my files.
In my case, the better approach turned out to be replaying tool invocations from the conversation log directly. The conversation log has every tool call (such as Read, Write, Edit, and Bash) from every session. Reads show partial or full file content. Writes create or overwrite a whole file. Edits record incremental changes. So the approach here was to find the latest Read or Write, then replay any subsequent Edits on top of it - reconstructing the file to its exact state at the moment everything was deleted.
The Tool
I hacked together a tool to automate this and got my codebase back, all the way up to the last edit before the deletion. That tool is claude-decoder - it parses Claude Code's session logs, reconstructs source files with diff previews, and lets you browse, search, or export conversation history.
So, this is just another one of those "AI ate my homework" stories, but with a data recovery twist, right? We should end this post here?
AI did not eat my homework
I started digging through the conversation history to figure out exactly where the project could have been deleted. Re-reading the conversation, I came to the conclusion that it must have been this one command:
rm -rf examples/minimal/build/*.cap examples/minimal/build/*.jca 2>/dev/null
uv run python -m backend3 examples/minimal --jcc-root . 2>&1 | tail -3
Here's a challenge: figure out how this command would delete the entire project.
I read this command, I asked coworkers to read this command, and none of us could explain how it had caused the entire project to be deleted. Even with some wild shell globbing, none of the filenames I had could have caused this behavior.
AI sorta ate my homework
Do you know how Claude Code works? Do you really know how Claude Code works?
It's a big project. In a particularly wild note, one of the developers described it as closer to "a small game engine" than a TUI. It constructs a React scene graph, lays out elements, rasterizes to a 2D screen, diffs against the previous frame, and generates ANSI sequences to write to your terminal. There's a lot going on here. It's also entirely coded with Claude Code.
There's also a lot going on with command execution. Commands aren't passed straight to the shell as they're presented to you. Claude Code wraps your input in an execution harness:
/bin/bash -lc 'source [shell-snapshot] && eval "[your-command]" < /dev/null && pwd -P >| [cwd-tracking-file]'
When a command contains a pipe (|), preprocessing logic restructures the command to handle this redirect. There's a known, still-open bug (GitHub Issue #15599) where this preprocessing collapses newlines to spaces when a pipe is present, merging what should be separate commands into one.
The original two-line command that Claude generated:
rm -rf examples/minimal/build/*.cap examples/minimal/build/*.jca 2>/dev/null
uv run python -m backend3 examples/minimal --jcc-root . 2>&1 | tail -3
Became a single line. Something along the lines of:
rm -rf examples/minimal/build/*.cap examples/minimal/build/*.jca 2>/dev/null uv run python -m backend3 examples/minimal --jcc-root . 2>&1 | tail -3
See it now?
The Deletion Anatomy
Every token after 2>/dev/null that was meant for uv run became an argument to rm -rf:
| Argument | Intended for | What rm -rf did with it |
examples/minimal/build/*.cap |
rm (intended) | Deleted matching files |
examples/minimal/build/*.jca |
rm (intended) | Deleted matching files |
uv |
uv run command |
Tried to delete (didn't exist) |
run |
uv run argument |
Tried to delete (didn't exist) |
python |
uv run argument |
Tried to delete (didn't exist) |
-m |
python flag | Ignored by rm (not a valid rm flag) |
backend3 |
python module name | Tried to delete (didn't exist) |
examples/minimal |
build target path | Deleted the minimal example |
--jcc-root |
build flag | Tried to delete (didn't exist) |
. |
current directory | Tried to delete (disallowed by rm) |
The 2>/dev/null on the first line and 2>&1 on the second became redirects on the merged command, with the last one winning. That's why the . error appeared in stdout rather than stderr:
rm: "." and ".." may not be removed
That error only shows up when rm receives . as an argument. The . came from --jcc-root . - never supposed to be an argument to rm, and hence why we see the error. However, while rm -rf skips . and .., it still processes every other argument. The project root survived only because . is protected.
But this is a story about my entire project being deleted. So, what happened?
I've been lying to you.
Initially, I thought the command I showed you was the one that deleted my project. In reality, it only deleted examples/minimal/. That was enough to confuse Claude, though. It started running other commands to try to figure out why it couldn't find examples/minimal/. The next command it generated used absolute paths instead of relative ones:
rm -rf /Users/trey/Documents/Projects/backend3/examples/correctness/build/*.cap /Users/trey/Documents/Projects/backend3/examples/correctness/build/*.jca 2>/dev/null
uv run python -m backend3 /Users/trey/Documents/Projects/backend3/examples/correctness --jcc-root /Users/trey/Documents/Projects/backend3 2>&1 | tail -3
And now, look at what rm -rf receives from --jcc-root:
/Users/trey/Documents/Projects/backend3
That's not . - it's the absolute path to the project root. rm has no problem deleting that.
The response was empty. Every command after that returned exit code 1 with no output - the working directory no longer existed.
Claude tried to be more careful, and that's what killed the project.
This bug remains open as of version 2.1.41. The timeline:
- April 2025 (#774, v0.2.69): Pipes are completely broken. Commands with
|error out or produce garbage. Multi-line commands like mine would have failed harmlessly -rmruns on just the build artifacts,uv runfails in some confusing way, project survives. - May-July 2025 (#1132, #2383, #2851, #2859): Multiple reports identify the root cause - the
eval "..." < /dev/nullwrapper and how< /dev/nullinteracts with pipes. - ~Mid-2025 (1.0.x): A fix ships that makes single-line pipes work by adding preprocessing logic to detect
|and handle it specially. This fix also collapses newlines to spaces when a pipe is present. This is when my specific command becomes destructive. - August-December 2025 (#7255, #10077, #12637, #15951): Users keep losing data to
rm -rfcommands corrupted by the same parsing logic. - December 28, 2025 (#15599): The multi-line + pipe corruption is filed with clear reproduction steps.
- February 4, 2026: My project gets deleted. Bug still open.
Update (April 30, 2026): #15599 has since been closed as not planned.
Back to a strange error
Rewind a bit for a moment - I'm in ~/.claude recovering files, and I cd into the project directory Claude Code had been tracking. Claude Code mirrors your project's absolute path under ~/.claude/projects/, replacing slashes with dashes:
❯ cd ~/.claude/projects/-Users-trey-Documents-Projects-backend3/
string escape: -Users-trey-Documents-Projects-backend3: unknown option
~/.config/fish/functions/_tide_parent_dirs.fish (line 1):
string escape (
^
in command substitution
called on line 2 of file ~/.config/fish/functions/_tide_parent_dirs.fish
in function '_tide_parent_dirs' with arguments 'VARIABLE SET PWD'
called on line 1 of file embedded:functions/cd.fish
(Type 'help string' for related documentation)
❯
Weird to have that from just entering a directory. Unknown option? The directory name is a bit odd, starting with a dash. That's what Claude Code uses instead of / in a project path. But the error wasn't anything related to Claude Code, it was coming from my shell, fish. More specifically, it's coming from the prompt I use - Tide.
Tide calls string escape on directory path components, and this directory name starts with a dash. Meaning: the directory name is being parsed as a command-line flag. That's options injection - when untrusted data is interpreted as a flag to a command instead of as an operand.
Here's the vulnerable line:
# _tide_parent_dirs.fish:2
set -g _tide_parent_dirs (string escape (for dir in ...))
string escape is Fish's built-in for quoting strings so they're safe to pass through the shell. It has a --style flag that controls its escaping strategy, allowing you to safely escape text for different contexts like regex patterns or shell scripts. The bug is simple - it's called here on path components without a -- separator, so a path component that looks like a flag gets parsed as one.
string escape defaults to script mode, which quotes shell metacharacters like semicolons. But --style=regex switches it to only escape regex-special characters, leaving semicolons raw. And because there's no -- separator in Tide's call, a path component starting with --style=regex gets parsed as a flag.
The question is: how do we get --style=regex as a standalone argument when it's embedded in a directory path? We just need to know two things:
- Directory names can contain literal newline characters -
0x0abytes. - The
(for dir in ... end)call is a command substitution, and Fish splits command substitution output on newlines. So, a directory named:
foo
--style=regex
;touch PWNED;#
Gets split into three separate arguments:
foo--style=regex- parsed as a flag;touch PWNED;#- escaped in regex mode, which doesn't touch semicolons
That third argument, now carrying a raw semicolon, gets stored in the global variable _tide_parent_dirs. When Tide renders the prompt, it passes this into a fish -c subprocess. The semicolon terminates the intended command, and touch PWNED executes as a separate statement.
Arbitrary command execution triggered by cd.
The most plausible attack path: victim clones a malicious repo, extracts an untrusted archive, or checks out a repo created elsewhere, and cds into it.
What about other prompts?
The idea that the filesystem is an injection surface isn't new. In 2014, Leon Juranic published "Unix Wildcards Gone Wild", showing that filenames like --checkpoint-action=exec=sh shell.sh get expanded by shell globs into command-line arguments for tar, rsync, chown. The shell does the glob before the program sees it, so the program can't distinguish a real flag from a malicious filename.
Shell prompt frameworks are a constantly-running version of this. They execute on every prompt render, pulling in directory names, git branch names, version strings, and interpolating them into shell commands, eval strings, interpreter invocations. All an attacker needs is for you to cd into the wrong directory.
If Tide is interpolating unsanitized path data into commands, others probably are too.
We shook this tree a bit more and 3 more bugs fell out.
Prezto (Zsh) - ${(e)...} eval injection via git branch name
Several Prezto themes (paradox, cloud, skwp, damoekri) use zsh's ${(e)...} parameter expansion flag on git_info values that include the branch name. In zsh, the (e) flag performs shell expansion on the string's contents - including command substitution. The branch name is stored in git_info[ref]:
# prompt_paradox_setup:53
prompt_paradox_start_segment green black '${(e)git_info[ref]}${(e)git_info[status]}'
A branch named $(cmd) runs cmd every time the prompt renders. Git's refname rules disallow whitespace and several characters, but they allow $, (, and ) - so $(id>PWNED) is a valid branch name.
You cd into the repo and it runs.
Spaceship (Zsh) - interpreter code injection via directory path
Spaceship's version detection in extract.zsh interpolates $file - a filesystem path - directly into interpreter code strings without escaping quotes:
# extract.zsh:57 (Ruby JSON path)
ruby -r json -e "puts ['version'].map { |key|
key.split('.').reduce(JSON::load(File.read('$file'))) { |obj, key|
obj[key] } }.find(&:itself)"
A single quote in the directory name breaks out of File.read('...'). The attacker has to close the surrounding Ruby syntax (the reduce block, map block, find call) before injecting their own code - messier than the other vulns, but straightforward to automate. Spaceship tries extractors in order - jq, yq, ruby, python3, node - so the injection depends on which interpreter is present. Triggers when you enter a directory containing package.json.
Liquidprompt (Bash) - gitstatus bypasses __lp_escape for PS1
When the optional gitstatus daemon is enabled, _lp_git_branch() copies the branch name straight into prompt variables without sanitization. This is a non-default configuration, but it's exactly the kind of "fast path" optimization that gets copied into dotfiles and forgotten about:
# Gitstatus path (vulnerable) - liquidprompt:3166
_lp_git_branch() {
if (( _LP_GITSTATUS_DATA )); then
lp_vcs_branch="${VCS_STATUS_LOCAL_BRANCH-}" # NO __lp_escape!
[[ -n "$lp_vcs_branch" ]] && return || return 1
fi
# ...
# Normal path (safe) - liquidprompt:3175
__lp_escape "${branch#refs/heads/}"
lp_vcs_branch="$ret"
__lp_escape() exists to escape $ and backticks before they hit PS1. Every other VCS backend in Liquidprompt calls it - Mercurial, SVN, Fossil, Bazaar. The gitstatus fast path doesn't. Bash's promptvars option (on by default) evaluates $(cmd) in the branch name. Same mechanism as Prezto.
Postmortem
We successfully demonstrated PoCs for all four bugs, each triggering command injection after a cd to a bad directory.
Disclosure status as of publication:
- Tide: upstream IlanCosman/tide is no longer maintained and remains vulnerable. Fixed in the maintained fork: plttn/tide v7.0.15.
- Prezto: fixed upstream: 5464030.
- Spaceship: fixed upstream: PR #1541 (v4.22.1).
- Liquidprompt: fixed upstream: a4f6b8d. Tracked as CVE-2026-27113 / GHSA-q6hm-vf4f-47jf.
Conclusion
Started with a fun side project. Ended with a deleted codebase, a recovery tool, a confirmed bug in Claude Code, and command injection in four shell prompt frameworks.
Push your commits. Don't approve multi-line pipes in Claude Code. And take a closer look at what your shell prompt is doing every time you press Enter.
Or don't. You might find some 0-days.