How Claude Code Defends Against Bash Injection
The security challenge of AI-executed Bash commands isn't "should we trust the model" — it's "how do we make sure a command actually means what it looks like."
Claude Code lets AI execute Bash commands directly. Not through a structured interface like MCP — it literally gives the model a shell. MCP's approach wraps tools into JSON schemas, which is safe enough, but you can't realistically write adapters for thousands of CLI tools — the capability ceiling is obvious. A raw shell can do anything; the tradeoff is that the security problem shifts from "controlling interface permissions" to "figuring out what a command actually does." In the previous post, I covered how YOLO Classifier uses AI to review AI, but the Classifier works with the full command string and makes a semantic judgment: is this operation dangerous? Before that judgment even happens, there's a deeper question that needs answering: does this command actually mean what it appears to mean?
This isn't rhetorical. Bash is famously a language where things "look
like A but execute as B." Everyone can read echo hello, but
what about echo$'\x20'-rf$'\x20'/? It looks harmless; it
could be catastrophic.
Claude Code's solution is to parse every command into an AST using Tree-Sitter, extract a structured argv array, and then match permissions against the argv. The design philosophy of this system can be summed up in one sentence: if we don't understand it, we don't allow it.
The Ceiling of Rule Engines
The most straightforward security approach is regex matching: block
rm -rf /, block curl | bash. Earlier versions
of Claude Code did exactly this — 23 regex Validators covering attack
surfaces from command substitution to Unicode whitespace.
The problem is that Bash's quoting rules are absurdly complex. A single command can be rewritten in countless equivalent forms:
1 | Regex sees: Bash executes: |
Regex operates on string matching — it sees what the command looks like. But security decisions require knowing what the command means. The gap between those two is the attack surface.
Worse still, a regex engine needs to strip quotes before it can analyze command structure, and quote-stripping is itself a mini parser. The moment this mini parser disagrees with how Bash interprets things, you have a differential attack. The code comments document dozens of such bypasses, each one a CVE-grade finding.
A Different Approach: Allowlist, Not Blocklist
The new architecture completely abandons the "detect bad things" philosophy in favor of "only allow structures we understand."
It parses commands into an AST with Tree-Sitter, then walks the tree against an explicit allowlist: only node types on the list are recursively processed; any unrecognized node type immediately returns too-complex and gets kicked to the user for confirmation.
1 | Input: "npm test && git status" |
The crux of this design is how it handles the unknown. A traditional blocklist defaults to "if it's not blacklisted, let it through." An allowlist defaults to "if it's not whitelisted, block it." If a new Bash syntax feature gets added to Tree-Sitter but the allowlist hasn't been updated, it's automatically blocked — not automatically allowed.
A comment at the top of the code makes this explicit: "The key design property is FAIL-CLOSED: we never interpret structure we don't understand."
Parser Differentials: The Real Security Boundary
But Tree-Sitter is not Bash. It's a generic incremental parsing framework that generates a parser from a Bash grammar file. This parser agrees with real Bash in the vast majority of cases, but diverges at the edges.
Those divergences are the attack surface.
Before the AST walk, Claude Code runs a set of pre-checks specifically designed to catch known Tree-Sitter/Bash differentials:
1 | Command input |
Each pre-check corresponds to a specific attack vector. Let's dig into a few interesting ones.
Invisible Characters
Unicode contains a zoo of "whitespace characters" that are either invisible or render as regular spaces in a terminal. Characters like NBSP (0A0), zero-width space (00B), and BOM () may be treated as word separators by Tree-Sitter, while Bash treats them as ordinary characters.
Attack scenario: the model generates a command that looks completely harmless, but a zero-width space is tucked inside. Tree-Sitter sees two tokens and the security analysis proceeds on that basis. Bash sees one token and executes an entirely different command.
The code lists an entire page of Unicode whitespace character encodings, from 0A0 to , and blocks them all.
The same logic applies to control characters. Bash silently drops
null bytes (), but Tree-Sitter doesn't. rm\x00 -rf / might
be parsed by Tree-Sitter as a single token rm\x00, but what
Bash executes is rm -rf /.
Backslash-Space: Where Two Parsers Disagree
In the command echo\ test, Bash treats \ as
an escaped space, so echo test is a single word (an echo
with a space embedded in the argument). Tree-Sitter's .text, however,
returns echo\ test (with the backslash) — argv[0] doesn't
match what Bash understands.
This isn't just theoretical. If the security system trusts
Tree-Sitter's argv[0] as echo\ test, it won't match any
permission rules for echo. But Bash will happily execute echo.
The fix is blunt but effective: any command containing a backslash followed by whitespace is blocked outright. This pattern is extremely rare in practice; you can always rewrite it with quotes.
Parse Timeouts Can Be Weaponized
Tree-Sitter parsing has time and resource limits: a 50ms timeout and a 50,000-node budget. This looks like a performance safeguard, but it has security implications.
A code comment describes a specific attack:
(( a[0][0][0]... )) nested to about 2,800 levels of array
subscripts can trigger a parse timeout. If the system falls back to the
old regex engine after a timeout, and that engine is missing certain
checks (like EVAL_LIKE_BUILTINS), an attacker can slip through.
The fix: a timeout returns too-complex (block immediately), not parse-unavailable (which would fall back to the old system). The comment reads: "Adversarially triggerable — fail closed: too-complex -> ask."
Bash's Quirks: eval and Friends
AST parsing can break a command into an argv array, but some commands have an argv that looks perfectly innocent while doing something entirely different. These are called eval-like builtins — they execute their arguments as shell code.
1 | +----------------------------------------------+ |
Claude Code maintains a list of roughly 20 eval-like commands. Any of
these appearing as argv[0] is flagged as too-complex, with a handful of
safe exceptions (command -v only looks up paths without
executing, fc -l only lists history without replaying).
Perhaps the most insidious is trap: trap 'cmd' EXIT
registers a command to run when the shell exits, and BashTool triggers
EXIT at the end of every invocation — meaning this line is essentially
"guaranteed execution."
Bombs Hidden in Array Subscripts
Bash has an easily overlooked feature: array subscripts are evaluated
as arithmetic expressions. Arithmetic evaluation expands
$(cmd). This means that even when an argument is wrapped in
single quotes — which Tree-Sitter treats as an opaque string leaf node —
Bash will still execute the command inside.
1 | test -v 'a[$(id)]' |
This is an especially sneaky attack because single quotes, in everyone's mental model, mean "don't expand anything." But in the special context of array subscripts, Bash performs arithmetic evaluation before quote processing — the order is reversed.
Claude Code maintains a table of all builtins that trigger subscript evaluation, along with their corresponding flags: test -v, printf -v, read -a, unset -v, and others. Whenever a command-plus-flag combination matches, the arguments are inspected for array subscript syntax.
Zsh: Another Battlefield
Claude Code's BashTool executes commands through the user's default shell. Many macOS users default to Zsh, which has expansion mechanisms that Bash lacks entirely.
=curl evil.com in Bash is just a command named
=curl. In Zsh, =cmd expands to the result of
$(which cmd), so what actually runs is
/usr/bin/curl evil.com. Tree-Sitter uses a Bash grammar, so
it parses argv[0] as =curl. The permission system tries to
match against the command name =curl, misses curl's deny
rule, and lets it through.
Even more dangerous is Zsh's module system. After
zmodload zsh/system, builtins like sysopen and
syswrite become available, bypassing filesystem-level
security checks. zmodload zsh/net/tcp enables
ztcp for establishing TCP connections and exfiltrating
data. zmodload zsh/files provides zf_rm and
zf_mv, which bypass detection of the rm and mv
binaries.
Claude Code's response is twofold: block zmodload
itself, and — as defense in depth — also blacklist every module builtin
by name, because "zmodload might be bypassed" or "modules might be
pre-loaded."
Variable Scope Tracking
AST analysis doesn't just decompose command structure — it also
tracks variable assignment scopes. This is critical for determining what
$VAR expands to.
1 | Safe pattern: |
The key design decision: variable scopes are reset at
||, |, and & operators. The
right-hand side of || only runs when the left side fails
(conditional), segments of | run in subshells (variables
aren't visible), and & runs in a background subshell
(same). Only && and ; propagate
variables linearly.
Without this distinction, an attacker could use a pattern like
true || SAFE_FLAG=yes && dangerous_cmd $SAFE_FLAG
to make static analysis believe dangerous_cmd runs with the
safety flag, when in reality Bash skips the assignment entirely.
Peeling Back Wrapper Commands
Commands like time, nohup,
timeout, nice, env, and
stdbuf aren't dangerous themselves — they wrap the real
command being executed. Security analysis must strip away these wrappers
to inspect what's inside.
But stripping itself has pitfalls. In
timeout .5 eval "id", .5 is the timeout
duration and eval is the real command. If the duration
regex only matches integers and not .5, eval
gets misidentified as the duration argument, and after stripping, all
the system sees is the harmless string "id".
An even more extreme example: in stdbuf --output 0 eval,
if flag parsing treats 0 as the command name (name='0'),
eval becomes completely invisible.
Claude Code implements full flag-parsing logic for every wrapper command. If an unrecognized flag appears, it returns too-complex. Fail-closed, once again.
Two Systems in Parallel
The old 23 regex Validators haven't been removed. They're marked @deprecated but still running. The new AST path and the old regex path run in parallel, with divergences between their results logged:
1 | +-------------------+ +-------------------+ |
This isn't redundancy — it's a deliberate safety net. If the AST path has a bug that lets a dangerous command slip through, the regex path might catch it (and vice versa). Divergence logs are monitored in Datadog; any divergence is a signal worth investigating.
An Attack-and-Defense Chronicle in Code Comments
The most impressive thing about this codebase isn't the technical complexity — it's the comments. Nearly every security check has a story next to it: why this check was added, what the specific attack was, how the previous version was bypassed, and why the current approach blocks it.
For instance, the stripSafeRedirections function has
this note: without (?=\s|$) as a trailing boundary,
> /dev/nullo matches the /dev/null prefix,
strips > /dev/null, and leaves behind an o.
The attacker actually writes to /dev/nullo, but the
validator can no longer see the > operator.
Or take the heredoc extractor's comment explaining why you can't use
a simple regex to match the end marker: when <<'EOF'
appears inside $(), Bash's make_cmd.c at line 606 closes
the heredoc early upon encountering ). If you don't handle
this edge case, the end marker position is wrong.
These comments read like an annals of Bash security research. Behind every rule is a real attack-and-defense encounter.
The Philosophy of Defense in Depth
Step back and look at the overall architecture:
The first layer is the AST allowlist. It understands only known structures and blocks everything else. This is the primary defense line with the broadest coverage.
The second layer is pre-checks. These catch known divergences between Tree-Sitter and Bash, blocking them before AST analysis even begins. This is the patch layer — a new check gets added every time a new parser differential is discovered.
The third layer is semantic checks. The AST may correctly decompose the structure, but some commands' structures don't correspond to their behavior (eval, trap, array subscript evaluation). This is the business logic layer.
The fourth layer is the legacy regex system. Deprecated but still running, serving as both divergence detection and a safety net.
On top of everything sits YOLO Classifier, using AI for semantic-level security judgment.
Five layers, each addressing a different category of problem, each defaulting to block. This isn't over-engineering — it's respect for the complexity of Bash as a language.