import { feature } from 'bun:bundle' import { APIUserAbortError } from '@anthropic-ai/sdk' import type { z } from 'zod/v4' import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from '../../services/analytics/index.js' import type { ToolPermissionContext, ToolUseContext } from '../../Tool.js' import type { PendingClassifierCheck } from '../../types/permissions.js' import { count } from '../../utils/array.js' import { checkSemantics, nodeTypeId, type ParseForSecurityResult, parseForSecurityFromAst, type Redirect, type SimpleCommand, } from '../../utils/bash/ast.js' import { type CommandPrefixResult, extractOutputRedirections, getCommandSubcommandPrefix, splitCommand_DEPRECATED, } from '../../utils/bash/commands.js' import { parseCommandRaw } from '../../utils/bash/parser.js' import { tryParseShellCommand } from '../../utils/bash/shellQuote.js' import { getCwd } from '../../utils/cwd.js' import { logForDebugging } from '../../utils/debug.js' import { isEnvTruthy } from '../../utils/envUtils.js' import { AbortError } from '../../utils/errors.js' import type { ClassifierBehavior, ClassifierResult, } from '../../utils/permissions/bashClassifier.js' import { classifyBashCommand, getBashPromptAllowDescriptions, getBashPromptAskDescriptions, getBashPromptDenyDescriptions, isClassifierPermissionsEnabled, } from '../../utils/permissions/bashClassifier.js' import type { PermissionDecisionReason, PermissionResult, } from '../../utils/permissions/PermissionResult.js' import type { PermissionRule, PermissionRuleValue, } from '../../utils/permissions/PermissionRule.js' import { extractRules } from '../../utils/permissions/PermissionUpdate.js' import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' import { permissionRuleValueToString } from '../../utils/permissions/permissionRuleParser.js' import { createPermissionRequestMessage, getRuleByContentsForTool, } from '../../utils/permissions/permissions.js' import { parsePermissionRule, type ShellPermissionRule, matchWildcardPattern as sharedMatchWildcardPattern, permissionRuleExtractPrefix as sharedPermissionRuleExtractPrefix, suggestionForExactCommand as sharedSuggestionForExactCommand, suggestionForPrefix as sharedSuggestionForPrefix, } from '../../utils/permissions/shellRuleMatching.js' import { getPlatform } from '../../utils/platform.js' import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' import { jsonStringify } from '../../utils/slowOperations.js' import { windowsPathToPosixPath } from '../../utils/windowsPaths.js' import { BashTool } from './BashTool.js' import { checkCommandOperatorPermissions } from './bashCommandHelpers.js' import { bashCommandIsSafeAsync_DEPRECATED, stripSafeHeredocSubstitutions, } from './bashSecurity.js' import { checkPermissionMode } from './modeValidation.js' import { checkPathConstraints } from './pathValidation.js' import { checkSedConstraints } from './sedValidation.js' import { shouldUseSandbox } from './shouldUseSandbox.js' // DCE cliff: Bun's feature() evaluator has a per-function complexity budget. // bashToolHasPermission is right at the limit. `import { X as Y }` aliases // inside the import block count toward this budget; when they push it over // the threshold Bun can no longer prove feature('BASH_CLASSIFIER') is a // constant and silently evaluates the ternaries to `false`, dropping every // pendingClassifierCheck spread. Keep aliases as top-level const rebindings // instead. (See also the comment on checkSemanticsDeny below.) const bashCommandIsSafeAsync = bashCommandIsSafeAsync_DEPRECATED const splitCommand = splitCommand_DEPRECATED // Env-var assignment prefix (VAR=value). Shared across three while-loops that // skip safe env vars before extracting the command name. const ENV_VAR_ASSIGN_RE = /^[A-Za-z_]\w*=/ // CC-643: On complex compound commands, splitCommand_DEPRECATED can produce a // very large subcommands array (possible exponential growth; #21405's ReDoS fix // may have been incomplete). Each subcommand then runs tree-sitter parse + // ~20 validators + logEvent (bashSecurity.ts), and with memoized metadata the // resulting microtask chain starves the event loop — REPL freeze at 100% CPU, // strace showed /proc/self/stat reads at ~127Hz with no epoll_wait. Fifty is // generous: legitimate user commands don't split that wide. Above the cap we // fall back to 'ask' (safe default — we can't prove safety, so we prompt). export const MAX_SUBCOMMANDS_FOR_SECURITY_CHECK = 50 // GH#11380: Cap the number of per-subcommand rules suggested for compound // commands. Beyond this, the "Yes, and don't ask again for X, Y, Z…" label // degrades to "similar commands" anyway, and saving 10+ rules from one prompt // is more likely noise than intent. Users chaining this many write commands // in one && list are rare; they can always approve once and add rules manually. export const MAX_SUGGESTED_RULES_FOR_COMPOUND = 5 /** * [ANT-ONLY] Log classifier evaluation results for analysis. * This helps us understand which classifier rules are being evaluated * and how the classifier is deciding on commands. */ function logClassifierResultForAnts( command: string, behavior: ClassifierBehavior, descriptions: string[], result: ClassifierResult, ): void { if (process.env.USER_TYPE !== 'ant') { return } logEvent('tengu_internal_bash_classifier_result', { behavior: behavior as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, descriptions: jsonStringify( descriptions, ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, matches: result.matches, matchedDescription: (result.matchedDescription ?? '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, confidence: result.confidence as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, reason: result.reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, // Note: command contains code/filepaths - this is ANT-ONLY so it's OK command: command as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) } /** * Extract a stable command prefix (command + subcommand) from a raw command string. * Skips leading env var assignments only if they are in SAFE_ENV_VARS (or * ANT_ONLY_SAFE_ENV_VARS for ant users). Returns null if a non-safe env var is * encountered (to fall back to exact match), or if the second token doesn't look * like a subcommand (lowercase alphanumeric, e.g., "commit", "run"). * * Examples: * 'git commit -m "fix typo"' → 'git commit' * 'NODE_ENV=prod npm run build' → 'npm run' (NODE_ENV is safe) * 'MY_VAR=val npm run build' → null (MY_VAR is not safe) * 'ls -la' → null (flag, not a subcommand) * 'cat file.txt' → null (filename, not a subcommand) * 'chmod 755 file' → null (number, not a subcommand) */ export function getSimpleCommandPrefix(command: string): string | null { const tokens = command.trim().split(/\s+/).filter(Boolean) if (tokens.length === 0) return null // Skip env var assignments (VAR=value) at the start, but only if they are // in SAFE_ENV_VARS (or ANT_ONLY_SAFE_ENV_VARS for ant users). If a non-safe // env var is encountered, return null to fall back to exact match. This // prevents generating prefix rules like Bash(npm run:*) that can never match // at allow-rule check time, because stripSafeWrappers only strips safe vars. let i = 0 while (i < tokens.length && ENV_VAR_ASSIGN_RE.test(tokens[i]!)) { const varName = tokens[i]!.split('=')[0]! const isAntOnlySafe = process.env.USER_TYPE === 'ant' && ANT_ONLY_SAFE_ENV_VARS.has(varName) if (!SAFE_ENV_VARS.has(varName) && !isAntOnlySafe) { return null } i++ } const remaining = tokens.slice(i) if (remaining.length < 2) return null const subcmd = remaining[1]! // Second token must look like a subcommand (e.g., "commit", "run", "compose"), // not a flag (-rf), filename (file.txt), path (/tmp), URL, or number (755). if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(subcmd)) return null return remaining.slice(0, 2).join(' ') } // Bare-prefix suggestions like `bash:*` or `sh:*` would allow arbitrary code // via `-c`. Wrapper suggestions like `env:*` or `sudo:*` would do the same: // `env` is NOT in SAFE_WRAPPER_PATTERNS, so `env bash -c "evil"` survives // stripSafeWrappers unchanged and hits the startsWith("env ") check at // the prefix-rule matcher. Shell list mirrors DANGEROUS_SHELL_PREFIXES in // src/utils/shell/prefix.ts which guarded the old Haiku extractor. const BARE_SHELL_PREFIXES = new Set([ 'sh', 'bash', 'zsh', 'fish', 'csh', 'tcsh', 'ksh', 'dash', 'cmd', 'powershell', 'pwsh', // wrappers that exec their args as a command 'env', 'xargs', // SECURITY: checkSemantics (ast.ts) strips these wrappers to check the // wrapped command. Suggesting `Bash(nice:*)` would be ≈ `Bash(*)` — users // would add it after a prompt, then `nice rm -rf /` passes semantics while // deny/cd+git gates see 'nice' (SAFE_WRAPPER_PATTERNS below didn't strip // bare `nice` until this fix). Block these from ever being suggested. 'nice', 'stdbuf', 'nohup', 'timeout', 'time', // privilege escalation — sudo:* from `sudo -u foo ...` would auto-approve // any future sudo invocation 'sudo', 'doas', 'pkexec', ]) /** * UI-only fallback: extract the first word alone when getSimpleCommandPrefix * declines. In external builds TREE_SITTER_BASH is off, so the async * tree-sitter refinement in BashPermissionRequest never fires — without this, * pipes and compounds (`python3 file.py 2>&1 | tail -20`) dump into the * editable field verbatim. * * Deliberately not used by suggestionForExactCommand: a backend-suggested * `Bash(rm:*)` is too broad to auto-generate, but as an editable starting * point it's what users expect (Slack C07VBSHV7EV/p1772670433193449). * * Reuses the same SAFE_ENV_VARS gate as getSimpleCommandPrefix — a rule like * `Bash(python3:*)` can never match `RUN=/path python3 ...` at check time * because stripSafeWrappers won't strip RUN. */ export function getFirstWordPrefix(command: string): string | null { const tokens = command.trim().split(/\s+/).filter(Boolean) let i = 0 while (i < tokens.length && ENV_VAR_ASSIGN_RE.test(tokens[i]!)) { const varName = tokens[i]!.split('=')[0]! const isAntOnlySafe = process.env.USER_TYPE === 'ant' && ANT_ONLY_SAFE_ENV_VARS.has(varName) if (!SAFE_ENV_VARS.has(varName) && !isAntOnlySafe) { return null } i++ } const cmd = tokens[i] if (!cmd) return null // Same shape check as the subcommand regex in getSimpleCommandPrefix: // rejects paths (./script.sh, /usr/bin/python), flags, numbers, filenames. if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(cmd)) return null if (BARE_SHELL_PREFIXES.has(cmd)) return null return cmd } function suggestionForExactCommand(command: string): PermissionUpdate[] { // Heredoc commands contain multi-line content that changes each invocation, // making exact-match rules useless (they'll never match again). Extract a // stable prefix before the heredoc operator and suggest a prefix rule instead. const heredocPrefix = extractPrefixBeforeHeredoc(command) if (heredocPrefix) { return sharedSuggestionForPrefix(BashTool.name, heredocPrefix) } // Multiline commands without heredoc also make poor exact-match rules. // Saving the full multiline text can produce patterns containing `:*` in // the middle, which fails permission validation and corrupts the settings // file. Use the first line as a prefix rule instead. if (command.includes('\n')) { const firstLine = command.split('\n')[0]!.trim() if (firstLine) { return sharedSuggestionForPrefix(BashTool.name, firstLine) } } // Single-line commands: extract a 2-word prefix for reusable rules. // Without this, exact-match rules are saved that never match future // invocations with different arguments. const prefix = getSimpleCommandPrefix(command) if (prefix) { return sharedSuggestionForPrefix(BashTool.name, prefix) } return sharedSuggestionForExactCommand(BashTool.name, command) } /** * If the command contains a heredoc (<<), extract the command prefix before it. * Returns the first word(s) before the heredoc operator as a stable prefix, * or null if the command doesn't contain a heredoc. * * Examples: * 'git commit -m "$(cat <<\'EOF\'\n...\nEOF\n)"' → 'git commit' * 'cat <= tokens.length) return null return tokens.slice(i, i + 2).join(' ') || null } function suggestionForPrefix(prefix: string): PermissionUpdate[] { return sharedSuggestionForPrefix(BashTool.name, prefix) } /** * Extract prefix from legacy :* syntax (e.g., "npm:*" -> "npm") * Delegates to shared implementation. */ export const permissionRuleExtractPrefix = sharedPermissionRuleExtractPrefix /** * Match a command against a wildcard pattern (case-sensitive for Bash). * Delegates to shared implementation. */ export function matchWildcardPattern( pattern: string, command: string, ): boolean { return sharedMatchWildcardPattern(pattern, command) } /** * Parse a permission rule into a structured rule object. * Delegates to shared implementation. */ export const bashPermissionRule: ( permissionRule: string, ) => ShellPermissionRule = parsePermissionRule /** * Whitelist of environment variables that are safe to strip from commands. * These variables CANNOT execute code or load libraries. * * SECURITY: These must NEVER be added to the whitelist: * - PATH, LD_PRELOAD, LD_LIBRARY_PATH, DYLD_* (execution/library loading) * - PYTHONPATH, NODE_PATH, CLASSPATH, RUBYLIB (module loading) * - GOFLAGS, RUSTFLAGS, NODE_OPTIONS (can contain code execution flags) * - HOME, TMPDIR, SHELL, BASH_ENV (affect system behavior) */ const SAFE_ENV_VARS = new Set([ // Go - build/runtime settings only 'GOEXPERIMENT', // experimental features 'GOOS', // target OS 'GOARCH', // target architecture 'CGO_ENABLED', // enable/disable CGO 'GO111MODULE', // module mode // Rust - logging/debugging only 'RUST_BACKTRACE', // backtrace verbosity 'RUST_LOG', // logging filter // Node - environment name only (not NODE_OPTIONS!) 'NODE_ENV', // Python - behavior flags only (not PYTHONPATH!) 'PYTHONUNBUFFERED', // disable buffering 'PYTHONDONTWRITEBYTECODE', // no .pyc files // Pytest - test configuration 'PYTEST_DISABLE_PLUGIN_AUTOLOAD', // disable plugin loading 'PYTEST_DEBUG', // debug output // API keys and authentication 'ANTHROPIC_API_KEY', // API authentication // Locale and character encoding 'LANG', // default locale 'LANGUAGE', // language preference list 'LC_ALL', // override all locale settings 'LC_CTYPE', // character classification 'LC_TIME', // time format 'CHARSET', // character set preference // Terminal and display 'TERM', // terminal type 'COLORTERM', // color terminal indicator 'NO_COLOR', // disable color output (universal standard) 'FORCE_COLOR', // force color output 'TZ', // timezone // Color configuration for various tools 'LS_COLORS', // colors for ls (GNU) 'LSCOLORS', // colors for ls (BSD/macOS) 'GREP_COLOR', // grep match color (deprecated) 'GREP_COLORS', // grep color scheme 'GCC_COLORS', // GCC diagnostic colors // Display formatting 'TIME_STYLE', // time display format for ls 'BLOCK_SIZE', // block size for du/df 'BLOCKSIZE', // alternative block size ]) /** * ANT-ONLY environment variables that are safe to strip from commands. * These are only enabled when USER_TYPE === 'ant'. * * SECURITY: These env vars are stripped before permission-rule matching, which * means `DOCKER_HOST=tcp://evil.com docker ps` matches a `Bash(docker ps:*)` * rule after stripping. This is INTENTIONALLY ANT-ONLY (gated at line ~380) * and MUST NEVER ship to external users. DOCKER_HOST redirects the Docker * daemon endpoint — stripping it defeats prefix-based permission restrictions * by hiding the network endpoint from the permission check. KUBECONFIG * similarly controls which cluster kubectl talks to. These are convenience * strippings for internal power users who accept the risk. * * Based on analysis of 30 days of tengu_internal_bash_tool_use_permission_request events. */ const ANT_ONLY_SAFE_ENV_VARS = new Set([ // Kubernetes and container config (config file pointers, not execution) 'KUBECONFIG', // kubectl config file path — controls which cluster kubectl uses 'DOCKER_HOST', // Docker daemon socket/endpoint — controls which daemon docker talks to // Cloud provider project/profile selection (just names/identifiers) 'AWS_PROFILE', // AWS profile name selection 'CLOUDSDK_CORE_PROJECT', // GCP project ID 'CLUSTER', // generic cluster name // Anthropic internal cluster selection (just names/identifiers) 'COO_CLUSTER', // coo cluster name 'COO_CLUSTER_NAME', // coo cluster name (alternate) 'COO_NAMESPACE', // coo namespace 'COO_LAUNCH_YAML_DRY_RUN', // dry run mode // Feature flags (boolean/string flags only) 'SKIP_NODE_VERSION_CHECK', // skip version check 'EXPECTTEST_ACCEPT', // accept test expectations 'CI', // CI environment indicator 'GIT_LFS_SKIP_SMUDGE', // skip LFS downloads // GPU/Device selection (just device IDs) 'CUDA_VISIBLE_DEVICES', // GPU device selection 'JAX_PLATFORMS', // JAX platform selection // Display/terminal settings 'COLUMNS', // terminal width 'TMUX', // TMUX socket info // Test/debug configuration 'POSTGRESQL_VERSION', // postgres version string 'FIRESTORE_EMULATOR_HOST', // emulator host:port 'HARNESS_QUIET', // quiet mode flag 'TEST_CROSSCHECK_LISTS_MATCH_UPDATE', // test update flag 'DBT_PER_DEVELOPER_ENVIRONMENTS', // DBT config 'STATSIG_FORD_DB_CHECKS', // statsig DB check flag // Build configuration 'ANT_ENVIRONMENT', // Anthropic environment name 'ANT_SERVICE', // Anthropic service name 'MONOREPO_ROOT_DIR', // monorepo root path // Version selectors 'PYENV_VERSION', // Python version selection // Credentials (approved subset - these don't change exfil risk) 'PGPASSWORD', // Postgres password 'GH_TOKEN', // GitHub token 'GROWTHBOOK_API_KEY', // self-hosted growthbook ]) /** * Strips full-line comments from a command. * This handles cases where Claude adds comments in bash commands, e.g.: * "# Check the logs directory\nls /home/user/logs" * Should be stripped to: "ls /home/user/logs" * * Only strips full-line comments (lines where the entire line is a comment), * not inline comments that appear after a command on the same line. */ function stripCommentLines(command: string): string { const lines = command.split('\n') const nonCommentLines = lines.filter(line => { const trimmed = line.trim() // Keep lines that are not empty and don't start with # return trimmed !== '' && !trimmed.startsWith('#') }) // If all lines were comments/empty, return original if (nonCommentLines.length === 0) { return command } return nonCommentLines.join('\n') } export function stripSafeWrappers(command: string): string { // SECURITY: Use [ \t]+ not \s+ — \s matches \n/\r which are command // separators in bash. Matching across a newline would strip the wrapper from // one line and leave a different command on the next line for bash to execute. // // SECURITY: `(?:--[ \t]+)?` consumes the wrapper's own `--` so // `nohup -- rm -- -/../foo` strips to `rm -- -/../foo` (not `-- rm ...` // which would skip path validation with `--` as an unknown baseCmd). const SAFE_WRAPPER_PATTERNS = [ // timeout: enumerate GNU long flags — no-value (--foreground, // --preserve-status, --verbose), value-taking in both =fused and // space-separated forms (--kill-after=5, --kill-after 5, --signal=TERM, // --signal TERM). Short: -v (no-arg), -k/-s with separate or fused value. // SECURITY: flag VALUES use allowlist [A-Za-z0-9_.+-] (signals are // TERM/KILL/9, durations are 5/5s/10.5). Previously [^ \t]+ matched // $ ( ) ` | ; & — `timeout -k$(id) 10 ls` stripped to `ls`, matched // Bash(ls:*), while bash expanded $(id) during word splitting BEFORE // timeout ran. Contrast ENV_VAR_PATTERN below which already allowlists. /^timeout[ \t]+(?:(?:--(?:foreground|preserve-status|verbose)|--(?:kill-after|signal)=[A-Za-z0-9_.+-]+|--(?:kill-after|signal)[ \t]+[A-Za-z0-9_.+-]+|-v|-[ks][ \t]+[A-Za-z0-9_.+-]+|-[ks][A-Za-z0-9_.+-]+)[ \t]+)*(?:--[ \t]+)?\d+(?:\.\d+)?[smhd]?[ \t]+/, /^time[ \t]+(?:--[ \t]+)?/, // SECURITY: keep in sync with checkSemantics wrapper-strip (ast.ts // ~:1990-2080) AND stripWrappersFromArgv (pathValidation.ts ~:1260). // Previously this pattern REQUIRED `-n N`; checkSemantics already handled // bare `nice` and legacy `-N`. Asymmetry meant checkSemantics exposed the // wrapped command to semantic checks but deny-rule matching and the cd+git // gate saw the wrapper name. `nice rm -rf /` with Bash(rm:*) deny became // ask instead of deny; `cd evil && nice git status` skipped the bare-repo // RCE gate. PR #21503 fixed stripWrappersFromArgv; this was missed. // Now matches: `nice cmd`, `nice -n N cmd`, `nice -N cmd` (all forms // checkSemantics strips). /^nice(?:[ \t]+-n[ \t]+-?\d+|[ \t]+-\d+)?[ \t]+(?:--[ \t]+)?/, // stdbuf: fused short flags only (-o0, -eL). checkSemantics handles more // (space-separated, long --output=MODE), but we fail-closed on those // above so not over-stripping here is safe. Main need: `stdbuf -o0 cmd`. /^stdbuf(?:[ \t]+-[ioe][LN0-9]+)+[ \t]+(?:--[ \t]+)?/, /^nohup[ \t]+(?:--[ \t]+)?/, ] as const // Pattern for environment variables: // ^([A-Za-z_][A-Za-z0-9_]*) - Variable name (standard identifier) // = - Equals sign // ([A-Za-z0-9_./:-]+) - Value: alphanumeric + safe punctuation only // [ \t]+ - Required HORIZONTAL whitespace after value // // SECURITY: Only matches unquoted values with safe characters (no $(), `, $var, ;|&). // // SECURITY: Trailing whitespace MUST be [ \t]+ (horizontal only), NOT \s+. // \s matches \n/\r. If reconstructCommand emits an unquoted newline between // `TZ=UTC` and `echo`, \s+ would match across it and strip `TZ=UTC`, // leaving `echo curl evil.com` to match Bash(echo:*). But bash treats the // newline as a command separator. Defense-in-depth with needsQuoting fix. const ENV_VAR_PATTERN = /^([A-Za-z_][A-Za-z0-9_]*)=([A-Za-z0-9_./:-]+)[ \t]+/ let stripped = command let previousStripped = '' // Phase 1: Strip leading env vars and comments only. // In bash, env var assignments before a command (VAR=val cmd) are genuine // shell-level assignments. These are safe to strip for permission matching. while (stripped !== previousStripped) { previousStripped = stripped stripped = stripCommentLines(stripped) const envVarMatch = stripped.match(ENV_VAR_PATTERN) if (envVarMatch) { const varName = envVarMatch[1]! const isAntOnlySafe = process.env.USER_TYPE === 'ant' && ANT_ONLY_SAFE_ENV_VARS.has(varName) if (SAFE_ENV_VARS.has(varName) || isAntOnlySafe) { stripped = stripped.replace(ENV_VAR_PATTERN, '') } } } // Phase 2: Strip wrapper commands and comments only. Do NOT strip env vars. // Wrapper commands (timeout, time, nice, nohup) use execvp to run their // arguments, so VAR=val after a wrapper is treated as the COMMAND to execute, // not as an env var assignment. Stripping env vars here would create a // mismatch between what the parser sees and what actually executes. // (HackerOne #3543050) previousStripped = '' while (stripped !== previousStripped) { previousStripped = stripped stripped = stripCommentLines(stripped) for (const pattern of SAFE_WRAPPER_PATTERNS) { stripped = stripped.replace(pattern, '') } } return stripped.trim() } // SECURITY: allowlist for timeout flag VALUES (signals are TERM/KILL/9, // durations are 5/5s/10.5). Rejects $ ( ) ` | ; & and newlines that // previously matched via [^ \t]+ — `timeout -k$(id) 10 ls` must NOT strip. const TIMEOUT_FLAG_VALUE_RE = /^[A-Za-z0-9_.+-]+$/ /** * Parse timeout's GNU flags (long + short, fused + space-separated) and * return the argv index of the DURATION token, or -1 if flags are unparseable. * Enumerates: --foreground/--preserve-status/--verbose (no value), * --kill-after/--signal (value, both =fused and space-separated), -v (no * value), -k/-s (value, both fused and space-separated). * * Extracted from stripWrappersFromArgv to keep bashToolHasPermission under * Bun's feature() DCE complexity threshold — inlining this breaks * feature('BASH_CLASSIFIER') evaluation in classifier tests. */ function skipTimeoutFlags(a: readonly string[]): number { let i = 1 while (i < a.length) { const arg = a[i]! const next = a[i + 1] if ( arg === '--foreground' || arg === '--preserve-status' || arg === '--verbose' ) i++ else if (/^--(?:kill-after|signal)=[A-Za-z0-9_.+-]+$/.test(arg)) i++ else if ( (arg === '--kill-after' || arg === '--signal') && next && TIMEOUT_FLAG_VALUE_RE.test(next) ) i += 2 else if (arg === '--') { i++ break } // end-of-options marker else if (arg.startsWith('--')) return -1 else if (arg === '-v') i++ else if ( (arg === '-k' || arg === '-s') && next && TIMEOUT_FLAG_VALUE_RE.test(next) ) i += 2 else if (/^-[ks][A-Za-z0-9_.+-]+$/.test(arg)) i++ else if (arg.startsWith('-')) return -1 else break } return i } /** * Argv-level counterpart to stripSafeWrappers. Strips the same wrapper * commands (timeout, time, nice, nohup) from AST-derived argv. Env vars * are already separated into SimpleCommand.envVars so no env-var stripping. * * KEEP IN SYNC with SAFE_WRAPPER_PATTERNS above — if you add a wrapper * there, add it here too. */ export function stripWrappersFromArgv(argv: string[]): string[] { // SECURITY: Consume optional `--` after wrapper options, matching what the // wrapper does. Otherwise `['nohup','--','rm','--','-/../foo']` yields `--` // as baseCmd and skips path validation. See SAFE_WRAPPER_PATTERNS comment. let a = argv for (;;) { if (a[0] === 'time' || a[0] === 'nohup') { a = a.slice(a[1] === '--' ? 2 : 1) } else if (a[0] === 'timeout') { const i = skipTimeoutFlags(a) if (i < 0 || !a[i] || !/^\d+(?:\.\d+)?[smhd]?$/.test(a[i]!)) return a a = a.slice(i + 1) } else if ( a[0] === 'nice' && a[1] === '-n' && a[2] && /^-?\d+$/.test(a[2]) ) { a = a.slice(a[3] === '--' ? 4 : 3) } else { return a } } } /** * Env vars that make a *different binary* run (injection or resolution hijack). * Heuristic only — export-&& form bypasses this, and excludedCommands isn't a * security boundary anyway. */ export const BINARY_HIJACK_VARS = /^(LD_|DYLD_|PATH$)/ /** * Strip ALL leading env var prefixes from a command, regardless of whether the * var name is in the safe-list. * * Used for deny/ask rule matching: when a user denies `claude` or `rm`, the * command should stay blocked even if prefixed with arbitrary env vars like * `FOO=bar claude`. The safe-list restriction in stripSafeWrappers is correct * for allow rules (prevents `DOCKER_HOST=evil docker ps` from auto-matching * `Bash(docker ps:*)`), but deny rules must be harder to circumvent. * * Also used for sandbox.excludedCommands matching (not a security boundary — * permission prompts are), with BINARY_HIJACK_VARS as a blocklist. * * SECURITY: Uses a broader value pattern than stripSafeWrappers. The value * pattern excludes only actual shell injection characters ($, backtick, ;, |, * &, parens, redirects, quotes, backslash) and whitespace. Characters like * =, +, @, ~, , are harmless in unquoted env var assignment position and must * be matched to prevent trivial bypass via e.g. `FOO=a=b denied_command`. * * @param blocklist - optional regex tested against each var name; matching vars * are NOT stripped (and stripping stops there). Omit for deny rules; pass * BINARY_HIJACK_VARS for excludedCommands. */ export function stripAllLeadingEnvVars( command: string, blocklist?: RegExp, ): string { // Broader value pattern for deny-rule stripping. Handles: // // - Standard assignment (FOO=bar), append (FOO+=bar), array (FOO[0]=bar) // - Single-quoted values: '[^'\n\r]*' — bash suppresses all expansion // - Double-quoted values with backslash escapes: "(?:\\.|[^"$`\\\n\r])*" // In bash double quotes, only \$, \`, \", \\, and \newline are special. // Other \x sequences are harmless, so we allow \. inside double quotes. // We still exclude raw $ and ` (without backslash) to block expansion. // - Unquoted values: excludes shell metacharacters, allows backslash escapes // - Concatenated segments: FOO='x'y"z" — bash concatenates adjacent segments // // SECURITY: Trailing whitespace MUST be [ \t]+ (horizontal only), NOT \s+. // // The outer * matches one atomic unit per iteration: a complete quoted // string, a backslash-escape pair, or a single unquoted safe character. // The inner double-quote alternation (?:...|...)* is bounded by the // closing ", so it cannot interact with the outer * for backtracking. // // Note: $ is excluded from unquoted/double-quoted value classes to block // dangerous forms like $(cmd), ${var}, and $((expr)). This means // FOO=$VAR is not stripped — adding $VAR matching creates ReDoS risk // (CodeQL #671) and $VAR bypasses are low-priority. const ENV_VAR_PATTERN = /^([A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?)\+?=(?:'[^'\n\r]*'|"(?:\\.|[^"$`\\\n\r])*"|\\.|[^ \t\n\r$`;|&()<>\\\\'"])*[ \t]+/ let stripped = command let previousStripped = '' while (stripped !== previousStripped) { previousStripped = stripped stripped = stripCommentLines(stripped) const m = stripped.match(ENV_VAR_PATTERN) if (!m) continue if (blocklist?.test(m[1]!)) break stripped = stripped.slice(m[0].length) } return stripped.trim() } function filterRulesByContentsMatchingInput( input: z.infer, rules: Map, matchMode: 'exact' | 'prefix', { stripAllEnvVars = false, skipCompoundCheck = false, }: { stripAllEnvVars?: boolean; skipCompoundCheck?: boolean } = {}, ): PermissionRule[] { const command = input.command.trim() // Strip output redirections for permission matching // This allows rules like Bash(python:*) to match "python script.py > output.txt" // Security validation of redirection targets happens separately in checkPathConstraints const commandWithoutRedirections = extractOutputRedirections(command).commandWithoutRedirections // For exact matching, try both the original command (to preserve quotes) // and the command without redirections (to allow rules without redirections to match) // For prefix matching, only use the command without redirections const commandsForMatching = matchMode === 'exact' ? [command, commandWithoutRedirections] : [commandWithoutRedirections] // Strip safe wrapper commands (timeout, time, nice, nohup) and env vars for matching // This allows rules like Bash(npm install:*) to match "timeout 10 npm install foo" // or "GOOS=linux go build" const commandsToTry = commandsForMatching.flatMap(cmd => { const strippedCommand = stripSafeWrappers(cmd) return strippedCommand !== cmd ? [cmd, strippedCommand] : [cmd] }) // SECURITY: For deny/ask rules, also try matching after stripping ALL leading // env var prefixes. This prevents bypass via `FOO=bar denied_command` where // FOO is not in the safe-list. The safe-list restriction in stripSafeWrappers // is intentional for allow rules (see HackerOne #3543050), but deny rules // must be harder to circumvent — a denied command should stay denied // regardless of env var prefixes. // // We iteratively apply both stripping operations to all candidates until no // new candidates are produced (fixed-point). This handles interleaved patterns // like `nohup FOO=bar timeout 5 claude` where: // 1. stripSafeWrappers strips `nohup` → `FOO=bar timeout 5 claude` // 2. stripAllLeadingEnvVars strips `FOO=bar` → `timeout 5 claude` // 3. stripSafeWrappers strips `timeout 5` → `claude` (deny match) // // Without iteration, single-pass compositions miss multi-layer interleaving. if (stripAllEnvVars) { const seen = new Set(commandsToTry) let startIdx = 0 // Iterate until no new candidates are produced (fixed-point) while (startIdx < commandsToTry.length) { const endIdx = commandsToTry.length for (let i = startIdx; i < endIdx; i++) { const cmd = commandsToTry[i] if (!cmd) { continue } // Try stripping env vars const envStripped = stripAllLeadingEnvVars(cmd) if (!seen.has(envStripped)) { commandsToTry.push(envStripped) seen.add(envStripped) } // Try stripping safe wrappers const wrapperStripped = stripSafeWrappers(cmd) if (!seen.has(wrapperStripped)) { commandsToTry.push(wrapperStripped) seen.add(wrapperStripped) } } startIdx = endIdx } } // Precompute compound-command status for each candidate to avoid re-parsing // inside the rule filter loop (which would scale splitCommand calls with // rules.length × commandsToTry.length). The compound check only applies to // prefix/wildcard matching in 'prefix' mode, and only for allow rules. // SECURITY: deny/ask rules must match compound commands so they can't be // bypassed by wrapping a denied command in a compound expression. const isCompoundCommand = new Map() if (matchMode === 'prefix' && !skipCompoundCheck) { for (const cmd of commandsToTry) { if (!isCompoundCommand.has(cmd)) { isCompoundCommand.set(cmd, splitCommand(cmd).length > 1) } } } return Array.from(rules.entries()) .filter(([ruleContent]) => { const bashRule = bashPermissionRule(ruleContent) return commandsToTry.some(cmdToMatch => { switch (bashRule.type) { case 'exact': return bashRule.command === cmdToMatch case 'prefix': switch (matchMode) { // In 'exact' mode, only return true if the command exactly matches the prefix rule case 'exact': return bashRule.prefix === cmdToMatch case 'prefix': { // SECURITY: Don't allow prefix rules to match compound commands. // e.g., Bash(cd:*) must NOT match "cd /path && python3 evil.py". // In the normal flow commands are split before reaching here, but // shell escaping can defeat the first splitCommand pass — e.g., // cd src\&\& python3 hello.py → splitCommand → ["cd src&& python3 hello.py"] // which then looks like a single command that starts with "cd ". // Re-splitting the candidate here catches those cases. if (isCompoundCommand.get(cmdToMatch)) { return false } // Ensure word boundary: prefix must be followed by space or end of string // This prevents "ls:*" from matching "lsof" or "lsattr" if (cmdToMatch === bashRule.prefix) { return true } if (cmdToMatch.startsWith(bashRule.prefix + ' ')) { return true } // Also match "xargs " for bare xargs with no flags. // This allows Bash(grep:*) to match "xargs grep pattern", // and deny rules like Bash(rm:*) to block "xargs rm file". // Natural word-boundary: "xargs -n1 grep" does NOT start with // "xargs grep " so flagged xargs invocations are not matched. const xargsPrefix = 'xargs ' + bashRule.prefix if (cmdToMatch === xargsPrefix) { return true } return cmdToMatch.startsWith(xargsPrefix + ' ') } } break case 'wildcard': // SECURITY FIX: In exact match mode, wildcards must NOT match because we're // checking the full unparsed command. Wildcard matching on unparsed commands // allows "foo *" to match "foo arg && curl evil.com" since .* matches operators. // Wildcards should only match after splitting into individual subcommands. if (matchMode === 'exact') { return false } // SECURITY: Same as for prefix rules, don't allow wildcard rules to match // compound commands in prefix mode. e.g., Bash(cd *) must not match // "cd /path && python3 evil.py" even though "cd *" pattern would match it. if (isCompoundCommand.get(cmdToMatch)) { return false } // In prefix mode (after splitting), wildcards can safely match subcommands return matchWildcardPattern(bashRule.pattern, cmdToMatch) } }) }) .map(([, rule]) => rule) } function matchingRulesForInput( input: z.infer, toolPermissionContext: ToolPermissionContext, matchMode: 'exact' | 'prefix', { skipCompoundCheck = false }: { skipCompoundCheck?: boolean } = {}, ) { const denyRuleByContents = getRuleByContentsForTool( toolPermissionContext, BashTool, 'deny', ) // SECURITY: Deny/ask rules use aggressive env var stripping so that // `FOO=bar denied_command` still matches a deny rule for `denied_command`. const matchingDenyRules = filterRulesByContentsMatchingInput( input, denyRuleByContents, matchMode, { stripAllEnvVars: true, skipCompoundCheck: true }, ) const askRuleByContents = getRuleByContentsForTool( toolPermissionContext, BashTool, 'ask', ) const matchingAskRules = filterRulesByContentsMatchingInput( input, askRuleByContents, matchMode, { stripAllEnvVars: true, skipCompoundCheck: true }, ) const allowRuleByContents = getRuleByContentsForTool( toolPermissionContext, BashTool, 'allow', ) const matchingAllowRules = filterRulesByContentsMatchingInput( input, allowRuleByContents, matchMode, { skipCompoundCheck }, ) return { matchingDenyRules, matchingAskRules, matchingAllowRules, } } /** * Checks if the subcommand is an exact match for a permission rule */ export const bashToolCheckExactMatchPermission = ( input: z.infer, toolPermissionContext: ToolPermissionContext, ): PermissionResult => { const command = input.command.trim() const { matchingDenyRules, matchingAskRules, matchingAllowRules } = matchingRulesForInput(input, toolPermissionContext, 'exact') // 1. Deny if exact command was denied if (matchingDenyRules[0] !== undefined) { return { behavior: 'deny', message: `Permission to use ${BashTool.name} with command ${command} has been denied.`, decisionReason: { type: 'rule', rule: matchingDenyRules[0], }, } } // 2. Ask if exact command was in ask rules if (matchingAskRules[0] !== undefined) { return { behavior: 'ask', message: createPermissionRequestMessage(BashTool.name), decisionReason: { type: 'rule', rule: matchingAskRules[0], }, } } // 3. Allow if exact command was allowed if (matchingAllowRules[0] !== undefined) { return { behavior: 'allow', updatedInput: input, decisionReason: { type: 'rule', rule: matchingAllowRules[0], }, } } // 4. Otherwise, passthrough const decisionReason = { type: 'other' as const, reason: 'This command requires approval', } return { behavior: 'passthrough', message: createPermissionRequestMessage(BashTool.name, decisionReason), decisionReason, // Suggest exact match rule to user // this may be overridden by prefix suggestions in `checkCommandAndSuggestRules()` suggestions: suggestionForExactCommand(command), } } export const bashToolCheckPermission = ( input: z.infer, toolPermissionContext: ToolPermissionContext, compoundCommandHasCd?: boolean, astCommand?: SimpleCommand, ): PermissionResult => { const command = input.command.trim() // 1. Check exact match first const exactMatchResult = bashToolCheckExactMatchPermission( input, toolPermissionContext, ) // 1a. Deny/ask if exact command has a rule if ( exactMatchResult.behavior === 'deny' || exactMatchResult.behavior === 'ask' ) { return exactMatchResult } // 2. Find all matching rules (prefix or exact) // SECURITY FIX: Check Bash deny/ask rules BEFORE path constraints to prevent bypass // via absolute paths outside the project directory (HackerOne report) // When AST-parsed, the subcommand is already atomic — skip the legacy // splitCommand re-check that misparses mid-word # as compound. const { matchingDenyRules, matchingAskRules, matchingAllowRules } = matchingRulesForInput(input, toolPermissionContext, 'prefix', { skipCompoundCheck: astCommand !== undefined, }) // 2a. Deny if command has a deny rule if (matchingDenyRules[0] !== undefined) { return { behavior: 'deny', message: `Permission to use ${BashTool.name} with command ${command} has been denied.`, decisionReason: { type: 'rule', rule: matchingDenyRules[0], }, } } // 2b. Ask if command has an ask rule if (matchingAskRules[0] !== undefined) { return { behavior: 'ask', message: createPermissionRequestMessage(BashTool.name), decisionReason: { type: 'rule', rule: matchingAskRules[0], }, } } // 3. Check path constraints // This check comes after deny/ask rules so explicit rules take precedence. // SECURITY: When AST-derived argv is available for this subcommand, pass // it through so checkPathConstraints uses it directly instead of re-parsing // with shell-quote (which has a single-quote backslash bug that causes // parseCommandArguments to return [] and silently skip path validation). const pathResult = checkPathConstraints( input, getCwd(), toolPermissionContext, compoundCommandHasCd, astCommand?.redirects, astCommand ? [astCommand] : undefined, ) if (pathResult.behavior !== 'passthrough') { return pathResult } // 4. Allow if command had an exact match allow if (exactMatchResult.behavior === 'allow') { return exactMatchResult } // 5. Allow if command has an allow rule if (matchingAllowRules[0] !== undefined) { return { behavior: 'allow', updatedInput: input, decisionReason: { type: 'rule', rule: matchingAllowRules[0], }, } } // 5b. Check sed constraints (blocks dangerous sed operations before mode auto-allow) const sedConstraintResult = checkSedConstraints(input, toolPermissionContext) if (sedConstraintResult.behavior !== 'passthrough') { return sedConstraintResult } // 6. Check for mode-specific permission handling const modeResult = checkPermissionMode(input, toolPermissionContext) if (modeResult.behavior !== 'passthrough') { return modeResult } // 7. Check read-only rules if (BashTool.isReadOnly(input)) { return { behavior: 'allow', updatedInput: input, decisionReason: { type: 'other', reason: 'Read-only command is allowed', }, } } // 8. Passthrough since no rules match, will trigger permission prompt const decisionReason = { type: 'other' as const, reason: 'This command requires approval', } return { behavior: 'passthrough', message: createPermissionRequestMessage(BashTool.name, decisionReason), decisionReason, // Suggest exact match rule to user // this may be overridden by prefix suggestions in `checkCommandAndSuggestRules()` suggestions: suggestionForExactCommand(command), } } /** * Processes an individual subcommand and applies prefix checks & suggestions */ export async function checkCommandAndSuggestRules( input: z.infer, toolPermissionContext: ToolPermissionContext, commandPrefixResult: CommandPrefixResult | null | undefined, compoundCommandHasCd?: boolean, astParseSucceeded?: boolean, ): Promise { // 1. Check exact match first const exactMatchResult = bashToolCheckExactMatchPermission( input, toolPermissionContext, ) if (exactMatchResult.behavior !== 'passthrough') { return exactMatchResult } // 2. Check the command prefix const permissionResult = bashToolCheckPermission( input, toolPermissionContext, compoundCommandHasCd, ) // 2a. Deny/ask if command was explictly denied/asked if ( permissionResult.behavior === 'deny' || permissionResult.behavior === 'ask' ) { return permissionResult } // 3. Ask for permission if command injection is detected. Skip when the // AST parse already succeeded — tree-sitter has verified there are no // hidden substitutions or structural tricks, so the legacy regex-based // validators (backslash-escaped operators, etc.) would only add FPs. if ( !astParseSucceeded && !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_COMMAND_INJECTION_CHECK) ) { const safetyResult = await bashCommandIsSafeAsync(input.command) if (safetyResult.behavior !== 'passthrough') { const decisionReason: PermissionDecisionReason = { type: 'other' as const, reason: safetyResult.behavior === 'ask' && safetyResult.message ? safetyResult.message : 'This command contains patterns that could pose security risks and requires approval', } return { behavior: 'ask', message: createPermissionRequestMessage(BashTool.name, decisionReason), decisionReason, suggestions: [], // Don't suggest saving a potentially dangerous command } } } // 4. Allow if command was allowed if (permissionResult.behavior === 'allow') { return permissionResult } // 5. Suggest prefix if available, otherwise exact command const suggestedUpdates = commandPrefixResult?.commandPrefix ? suggestionForPrefix(commandPrefixResult.commandPrefix) : suggestionForExactCommand(input.command) return { ...permissionResult, suggestions: suggestedUpdates, } } /** * Checks if a command should be auto-allowed when sandboxed. * Returns early if there are explicit deny/ask rules that should be respected. * * NOTE: This function should only be called when sandboxing and auto-allow are enabled. * * @param input - The bash tool input * @param toolPermissionContext - The permission context * @returns PermissionResult with: * - deny/ask if explicit rule exists (exact or prefix) * - allow if no explicit rules (sandbox auto-allow applies) * - passthrough should not occur since we're in auto-allow mode */ function checkSandboxAutoAllow( input: z.infer, toolPermissionContext: ToolPermissionContext, ): PermissionResult { const command = input.command.trim() // Check for explicit deny/ask rules on the full command (exact + prefix) const { matchingDenyRules, matchingAskRules } = matchingRulesForInput( input, toolPermissionContext, 'prefix', ) // Return immediately if there's an explicit deny rule on the full command if (matchingDenyRules[0] !== undefined) { return { behavior: 'deny', message: `Permission to use ${BashTool.name} with command ${command} has been denied.`, decisionReason: { type: 'rule', rule: matchingDenyRules[0], }, } } // SECURITY: For compound commands, check each subcommand against deny/ask // rules. Prefix rules like Bash(rm:*) won't match the full compound command // (e.g., "echo hello && rm -rf /" doesn't start with "rm"), so we must // check each subcommand individually. // IMPORTANT: Subcommand deny checks must run BEFORE full-command ask returns. // Otherwise a wildcard ask rule matching the full command (e.g., Bash(*echo*)) // would return 'ask' before a prefix deny rule on a subcommand (e.g., Bash(rm:*)) // gets checked, downgrading a deny to an ask. const subcommands = splitCommand(command) if (subcommands.length > 1) { let firstAskRule: PermissionRule | undefined for (const sub of subcommands) { const subResult = matchingRulesForInput( { command: sub }, toolPermissionContext, 'prefix', ) // Deny takes priority — return immediately if (subResult.matchingDenyRules[0] !== undefined) { return { behavior: 'deny', message: `Permission to use ${BashTool.name} with command ${command} has been denied.`, decisionReason: { type: 'rule', rule: subResult.matchingDenyRules[0], }, } } // Stash first ask match; don't return yet (deny across all subs takes priority) firstAskRule ??= subResult.matchingAskRules[0] } if (firstAskRule) { return { behavior: 'ask', message: createPermissionRequestMessage(BashTool.name), decisionReason: { type: 'rule', rule: firstAskRule, }, } } } // Full-command ask check (after all deny sources have been exhausted) if (matchingAskRules[0] !== undefined) { return { behavior: 'ask', message: createPermissionRequestMessage(BashTool.name), decisionReason: { type: 'rule', rule: matchingAskRules[0], }, } } // No explicit rules, so auto-allow with sandbox return { behavior: 'allow', updatedInput: input, decisionReason: { type: 'other', reason: 'Auto-allowed with sandbox (autoAllowBashIfSandboxed enabled)', }, } } /** * Filter out `cd ${cwd}` prefix subcommands, keeping astCommands aligned. * Extracted to keep bashToolHasPermission under Bun's feature() DCE * complexity threshold — inlining this breaks pendingClassifierCheck * attachment in ~10 classifier tests. */ function filterCdCwdSubcommands( rawSubcommands: string[], astCommands: SimpleCommand[] | undefined, cwd: string, cwdMingw: string, ): { subcommands: string[]; astCommandsByIdx: (SimpleCommand | undefined)[] } { const subcommands: string[] = [] const astCommandsByIdx: (SimpleCommand | undefined)[] = [] for (let i = 0; i < rawSubcommands.length; i++) { const cmd = rawSubcommands[i]! if (cmd === `cd ${cwd}` || cmd === `cd ${cwdMingw}`) continue subcommands.push(cmd) astCommandsByIdx.push(astCommands?.[i]) } return { subcommands, astCommandsByIdx } } /** * Early-exit deny enforcement for the AST too-complex and checkSemantics * paths. Returns the exact-match result if non-passthrough (deny/ask/allow), * then checks prefix/wildcard deny rules. Returns null if neither matched, * meaning the caller should fall through to ask. Extracted to keep * bashToolHasPermission under Bun's feature() DCE complexity threshold. */ function checkEarlyExitDeny( input: z.infer, toolPermissionContext: ToolPermissionContext, ): PermissionResult | null { const exactMatchResult = bashToolCheckExactMatchPermission( input, toolPermissionContext, ) if (exactMatchResult.behavior !== 'passthrough') { return exactMatchResult } const denyMatch = matchingRulesForInput( input, toolPermissionContext, 'prefix', ).matchingDenyRules[0] if (denyMatch !== undefined) { return { behavior: 'deny', message: `Permission to use ${BashTool.name} with command ${input.command} has been denied.`, decisionReason: { type: 'rule', rule: denyMatch }, } } return null } /** * checkSemantics-path deny enforcement. Calls checkEarlyExitDeny (exact-match * + full-command prefix deny), then checks each individual SimpleCommand .text * span against prefix deny rules. The per-subcommand check is needed because * filterRulesByContentsMatchingInput has a compound-command guard * (splitCommand().length > 1 → prefix rules return false) that defeats * `Bash(eval:*)` matching against a full pipeline like `echo foo | eval rm`. * Each SimpleCommand span is a single command, so the guard doesn't fire. * * Separate helper (not folded into checkEarlyExitDeny or inlined at the call * site) because bashToolHasPermission is tight against Bun's feature() DCE * complexity threshold — adding even ~5 lines there breaks * feature('BASH_CLASSIFIER') evaluation and drops pendingClassifierCheck. */ function checkSemanticsDeny( input: z.infer, toolPermissionContext: ToolPermissionContext, commands: readonly { text: string }[], ): PermissionResult | null { const fullCmd = checkEarlyExitDeny(input, toolPermissionContext) if (fullCmd !== null) return fullCmd for (const cmd of commands) { const subDeny = matchingRulesForInput( { ...input, command: cmd.text }, toolPermissionContext, 'prefix', ).matchingDenyRules[0] if (subDeny !== undefined) { return { behavior: 'deny', message: `Permission to use ${BashTool.name} with command ${input.command} has been denied.`, decisionReason: { type: 'rule', rule: subDeny }, } } } return null } /** * Builds the pending classifier check metadata if classifier is enabled and has allow descriptions. * Returns undefined if classifier is disabled, in auto mode, or no allow descriptions exist. */ function buildPendingClassifierCheck( command: string, toolPermissionContext: ToolPermissionContext, ): { command: string; cwd: string; descriptions: string[] } | undefined { if (!isClassifierPermissionsEnabled()) { return undefined } // Skip in auto mode - auto mode classifier handles all permission decisions if (feature('TRANSCRIPT_CLASSIFIER') && toolPermissionContext.mode === 'auto') return undefined if (toolPermissionContext.mode === 'bypassPermissions') return undefined const allowDescriptions = getBashPromptAllowDescriptions( toolPermissionContext, ) if (allowDescriptions.length === 0) return undefined return { command, cwd: getCwd(), descriptions: allowDescriptions, } } const speculativeChecks = new Map>() /** * Start a speculative bash allow classifier check early, so it runs in * parallel with pre-tool hooks, deny/ask classifiers, and permission dialog setup. * The result can be consumed later by executeAsyncClassifierCheck via * consumeSpeculativeClassifierCheck. */ export function peekSpeculativeClassifierCheck( command: string, ): Promise | undefined { return speculativeChecks.get(command) } export function startSpeculativeClassifierCheck( command: string, toolPermissionContext: ToolPermissionContext, signal: AbortSignal, isNonInteractiveSession: boolean, ): boolean { // Same guards as buildPendingClassifierCheck if (!isClassifierPermissionsEnabled()) return false if (feature('TRANSCRIPT_CLASSIFIER') && toolPermissionContext.mode === 'auto') return false if (toolPermissionContext.mode === 'bypassPermissions') return false const allowDescriptions = getBashPromptAllowDescriptions( toolPermissionContext, ) if (allowDescriptions.length === 0) return false const cwd = getCwd() const promise = classifyBashCommand( command, cwd, allowDescriptions, 'allow', signal, isNonInteractiveSession, ) // Prevent unhandled rejection if the signal aborts before this promise is consumed. // The original promise (which may reject) is still stored in the Map for consumers to await. promise.catch(() => {}) speculativeChecks.set(command, promise) return true } /** * Consume a speculative classifier check result for the given command. * Returns the promise if one exists (and removes it from the map), or undefined. */ export function consumeSpeculativeClassifierCheck( command: string, ): Promise | undefined { const promise = speculativeChecks.get(command) if (promise) { speculativeChecks.delete(command) } return promise } export function clearSpeculativeChecks(): void { speculativeChecks.clear() } /** * Await a pending classifier check and return a PermissionDecisionReason if * high-confidence allow, or undefined otherwise. * * Used by swarm agents (both tmux and in-process) to gate permission * forwarding: run the classifier first, and only escalate to the leader * if the classifier doesn't auto-approve. */ export async function awaitClassifierAutoApproval( pendingCheck: PendingClassifierCheck, signal: AbortSignal, isNonInteractiveSession: boolean, ): Promise { const { command, cwd, descriptions } = pendingCheck const speculativeResult = consumeSpeculativeClassifierCheck(command) const classifierResult = speculativeResult ? await speculativeResult : await classifyBashCommand( command, cwd, descriptions, 'allow', signal, isNonInteractiveSession, ) logClassifierResultForAnts(command, 'allow', descriptions, classifierResult) if ( feature('BASH_CLASSIFIER') && classifierResult.matches && classifierResult.confidence === 'high' ) { return { type: 'classifier', classifier: 'bash_allow', reason: `Allowed by prompt rule: "${classifierResult.matchedDescription}"`, } } return undefined } type AsyncClassifierCheckCallbacks = { shouldContinue: () => boolean onAllow: (decisionReason: PermissionDecisionReason) => void onComplete?: () => void } /** * Execute the bash allow classifier check asynchronously. * This runs in the background while the permission prompt is shown. * If the classifier allows with high confidence and the user hasn't interacted, auto-approves. * * @param pendingCheck - Classifier check metadata from bashToolHasPermission * @param signal - Abort signal * @param isNonInteractiveSession - Whether this is a non-interactive session * @param callbacks - Callbacks to check if we should continue and handle approval */ export async function executeAsyncClassifierCheck( pendingCheck: { command: string; cwd: string; descriptions: string[] }, signal: AbortSignal, isNonInteractiveSession: boolean, callbacks: AsyncClassifierCheckCallbacks, ): Promise { const { command, cwd, descriptions } = pendingCheck const speculativeResult = consumeSpeculativeClassifierCheck(command) let classifierResult: ClassifierResult try { classifierResult = speculativeResult ? await speculativeResult : await classifyBashCommand( command, cwd, descriptions, 'allow', signal, isNonInteractiveSession, ) } catch (error: unknown) { // When the coordinator session is cancelled, the abort signal fires and the // classifier API call rejects with APIUserAbortError. This is expected and // should not surface as an unhandled promise rejection. if (error instanceof APIUserAbortError || error instanceof AbortError) { callbacks.onComplete?.() return } callbacks.onComplete?.() throw error } logClassifierResultForAnts(command, 'allow', descriptions, classifierResult) // Don't auto-approve if user already made a decision or has interacted // with the permission dialog (e.g., arrow keys, tab, typing) if (!callbacks.shouldContinue()) return if ( feature('BASH_CLASSIFIER') && classifierResult.matches && classifierResult.confidence === 'high' ) { callbacks.onAllow({ type: 'classifier', classifier: 'bash_allow', reason: `Allowed by prompt rule: "${classifierResult.matchedDescription}"`, }) } else { // No match — notify so the checking indicator is cleared callbacks.onComplete?.() } } /** * The main implementation to check if we need to ask for user permission to call BashTool with a given input */ export async function bashToolHasPermission( input: z.infer, context: ToolUseContext, getCommandSubcommandPrefixFn = getCommandSubcommandPrefix, ): Promise { let appState = context.getAppState() // 0. AST-based security parse. This replaces both tryParseShellCommand // (the shell-quote pre-check) and the bashCommandIsSafe misparsing gate. // tree-sitter produces either a clean SimpleCommand[] (quotes resolved, // no hidden substitutions) or 'too-complex' — which is exactly the signal // we need to decide whether splitCommand's output can be trusted. // // When tree-sitter WASM is unavailable OR the injection check is disabled // via env var, we fall back to the old path (legacy gate at ~1370 runs). const injectionCheckDisabled = isEnvTruthy( process.env.CLAUDE_CODE_DISABLE_COMMAND_INJECTION_CHECK, ) // GrowthBook killswitch for shadow mode — when off, skip the native parse // entirely. Computed once; feature() must stay inline in the ternary below. const shadowEnabled = feature('TREE_SITTER_BASH_SHADOW') ? getFeatureValue_CACHED_MAY_BE_STALE('tengu_birch_trellis', true) : false // Parse once here; the resulting AST feeds both parseForSecurityFromAst // and bashToolCheckCommandOperatorPermissions. let astRoot = injectionCheckDisabled ? null : feature('TREE_SITTER_BASH_SHADOW') && !shadowEnabled ? null : await parseCommandRaw(input.command) let astResult: ParseForSecurityResult = astRoot ? parseForSecurityFromAst(input.command, astRoot) : { kind: 'parse-unavailable' } let astSubcommands: string[] | null = null let astRedirects: Redirect[] | undefined let astCommands: SimpleCommand[] | undefined let shadowLegacySubs: string[] | undefined // Shadow-test tree-sitter: record its verdict, then force parse-unavailable // so the legacy path stays authoritative. parseCommand stays gated on // TREE_SITTER_BASH (not SHADOW) so legacy internals remain pure regex. // One event per bash call captures both divergence AND unavailability // reasons; module-load failures are separately covered by the // session-scoped tengu_tree_sitter_load event. if (feature('TREE_SITTER_BASH_SHADOW')) { const available = astResult.kind !== 'parse-unavailable' let tooComplex = false let semanticFail = false let subsDiffer = false if (available) { tooComplex = astResult.kind === 'too-complex' semanticFail = astResult.kind === 'simple' && !checkSemantics(astResult.commands).ok const tsSubs = astResult.kind === 'simple' ? astResult.commands.map(c => c.text) : undefined const legacySubs = splitCommand(input.command) shadowLegacySubs = legacySubs subsDiffer = tsSubs !== undefined && (tsSubs.length !== legacySubs.length || tsSubs.some((s, i) => s !== legacySubs[i])) } logEvent('tengu_tree_sitter_shadow', { available, astTooComplex: tooComplex, astSemanticFail: semanticFail, subsDiffer, injectionCheckDisabled, killswitchOff: !shadowEnabled, cmdOverLength: input.command.length > 10000, }) // Always force legacy — shadow mode is observational only. astResult = { kind: 'parse-unavailable' } astRoot = null } if (astResult.kind === 'too-complex') { // Parse succeeded but found structure we can't statically analyze // (command substitution, expansion, control flow, parser differential). // Respect exact-match deny/ask/allow, then prefix/wildcard deny. Only // fall through to ask if no deny matched — don't downgrade deny to ask. const earlyExit = checkEarlyExitDeny(input, appState.toolPermissionContext) if (earlyExit !== null) return earlyExit const decisionReason: PermissionDecisionReason = { type: 'other' as const, reason: astResult.reason, } logEvent('tengu_bash_ast_too_complex', { nodeTypeId: nodeTypeId(astResult.nodeType), }) return { behavior: 'ask', decisionReason, message: createPermissionRequestMessage(BashTool.name, decisionReason), suggestions: [], ...(feature('BASH_CLASSIFIER') ? { pendingClassifierCheck: buildPendingClassifierCheck( input.command, appState.toolPermissionContext, ), } : {}), } } if (astResult.kind === 'simple') { // Clean parse: check semantic-level concerns (zsh builtins, eval, etc.) // that tokenize fine but are dangerous by name. const sem = checkSemantics(astResult.commands) if (!sem.ok) { // Same deny-rule enforcement as the too-complex path: a user with // `Bash(eval:*)` deny expects `eval "rm"` blocked, not downgraded. const earlyExit = checkSemanticsDeny( input, appState.toolPermissionContext, astResult.commands, ) if (earlyExit !== null) return earlyExit const decisionReason: PermissionDecisionReason = { type: 'other' as const, reason: sem.reason, } return { behavior: 'ask', decisionReason, message: createPermissionRequestMessage(BashTool.name, decisionReason), suggestions: [], } } // Stash the tokenized subcommands for use below. Downstream code (rule // matching, path extraction, cd detection) still operates on strings, so // we pass the original source span for each SimpleCommand. Downstream // processing (stripSafeWrappers, parseCommandArguments) re-tokenizes // these spans — that re-tokenization has known bugs (stripCommentLines // mishandles newlines inside quotes), but checkSemantics already caught // any argv element containing a newline, so those bugs can't bite here. // Migrating downstream to operate on argv directly is a later commit. astSubcommands = astResult.commands.map(c => c.text) astRedirects = astResult.commands.flatMap(c => c.redirects) astCommands = astResult.commands } // Legacy shell-quote pre-check. Only reached on 'parse-unavailable' // (tree-sitter not loaded OR TREE_SITTER_BASH feature gated off). Falls // through to the full legacy path below. if (astResult.kind === 'parse-unavailable') { logForDebugging( 'bashToolHasPermission: tree-sitter unavailable, using legacy shell-quote path', ) const parseResult = tryParseShellCommand(input.command) if (!parseResult.success) { const decisionReason = { type: 'other' as const, reason: `Command contains malformed syntax that cannot be parsed: ${parseResult.error}`, } return { behavior: 'ask', decisionReason, message: createPermissionRequestMessage(BashTool.name, decisionReason), } } } // Check sandbox auto-allow (which respects explicit deny/ask rules) // Only call this if sandboxing and auto-allow are both enabled if ( SandboxManager.isSandboxingEnabled() && SandboxManager.isAutoAllowBashIfSandboxedEnabled() && shouldUseSandbox(input) ) { const sandboxAutoAllowResult = checkSandboxAutoAllow( input, appState.toolPermissionContext, ) if (sandboxAutoAllowResult.behavior !== 'passthrough') { return sandboxAutoAllowResult } } // Check exact match first const exactMatchResult = bashToolCheckExactMatchPermission( input, appState.toolPermissionContext, ) // Exact command was denied if (exactMatchResult.behavior === 'deny') { return exactMatchResult } // Check Bash prompt deny and ask rules in parallel (both use Haiku). // Deny takes precedence over ask, and both take precedence over allow rules. // Skip when in auto mode - auto mode classifier handles all permission decisions if ( isClassifierPermissionsEnabled() && !( feature('TRANSCRIPT_CLASSIFIER') && appState.toolPermissionContext.mode === 'auto' ) ) { const denyDescriptions = getBashPromptDenyDescriptions( appState.toolPermissionContext, ) const askDescriptions = getBashPromptAskDescriptions( appState.toolPermissionContext, ) const hasDeny = denyDescriptions.length > 0 const hasAsk = askDescriptions.length > 0 if (hasDeny || hasAsk) { const [denyResult, askResult] = await Promise.all([ hasDeny ? classifyBashCommand( input.command, getCwd(), denyDescriptions, 'deny', context.abortController.signal, context.options.isNonInteractiveSession, ) : null, hasAsk ? classifyBashCommand( input.command, getCwd(), askDescriptions, 'ask', context.abortController.signal, context.options.isNonInteractiveSession, ) : null, ]) if (context.abortController.signal.aborted) { throw new AbortError() } if (denyResult) { logClassifierResultForAnts( input.command, 'deny', denyDescriptions, denyResult, ) } if (askResult) { logClassifierResultForAnts( input.command, 'ask', askDescriptions, askResult, ) } // Deny takes precedence if (denyResult?.matches && denyResult.confidence === 'high') { return { behavior: 'deny', message: `Denied by Bash prompt rule: "${denyResult.matchedDescription}"`, decisionReason: { type: 'other', reason: `Denied by Bash prompt rule: "${denyResult.matchedDescription}"`, }, } } if (askResult?.matches && askResult.confidence === 'high') { // Skip the Haiku call — the UI computes the prefix locally // and lets the user edit it. Still call the injected function // when tests override it. let suggestions: PermissionUpdate[] if (getCommandSubcommandPrefixFn === getCommandSubcommandPrefix) { suggestions = suggestionForExactCommand(input.command) } else { const commandPrefixResult = await getCommandSubcommandPrefixFn( input.command, context.abortController.signal, context.options.isNonInteractiveSession, ) if (context.abortController.signal.aborted) { throw new AbortError() } suggestions = commandPrefixResult?.commandPrefix ? suggestionForPrefix(commandPrefixResult.commandPrefix) : suggestionForExactCommand(input.command) } return { behavior: 'ask', message: createPermissionRequestMessage(BashTool.name), decisionReason: { type: 'other', reason: `Required by Bash prompt rule: "${askResult.matchedDescription}"`, }, suggestions, ...(feature('BASH_CLASSIFIER') ? { pendingClassifierCheck: buildPendingClassifierCheck( input.command, appState.toolPermissionContext, ), } : {}), } } } } // Check for non-subcommand Bash operators like `>`, `|`, etc. // This must happen before dangerous path checks so that piped commands // are handled by the operator logic (which generates "multiple operations" messages) const commandOperatorResult = await checkCommandOperatorPermissions( input, (i: z.infer) => bashToolHasPermission(i, context, getCommandSubcommandPrefixFn), { isNormalizedCdCommand, isNormalizedGitCommand }, astRoot, ) if (commandOperatorResult.behavior !== 'passthrough') { // SECURITY FIX: When pipe segment processing returns 'allow', we must still validate // the ORIGINAL command. The pipe segment processing strips redirections before // checking each segment, so commands like: // echo 'x' | xargs printf '%s' >> /tmp/file // would have both segments allowed (echo and xargs printf) but the >> redirection // would bypass validation. We must check: // 1. Path constraints for output redirections // 2. Command safety for dangerous patterns (backticks, etc.) in redirect targets if (commandOperatorResult.behavior === 'allow') { // Check for dangerous patterns (backticks, $(), etc.) in the original command // This catches cases like: echo x | xargs echo > `pwd`/evil.txt // where the backtick is in the redirect target (stripped from segments) // Gate on AST: when astSubcommands is non-null, tree-sitter already // validated structure (backticks/$() in redirect targets would have // returned too-complex). Matches gating at ~1481, ~1706, ~1755. // Avoids FP: `find -exec {} \; | grep x` tripping on backslash-;. // bashCommandIsSafe runs the full legacy regex battery (~20 patterns) — // only call it when we'll actually use the result. const safetyResult = astSubcommands === null ? await bashCommandIsSafeAsync(input.command) : null if ( safetyResult !== null && safetyResult.behavior !== 'passthrough' && safetyResult.behavior !== 'allow' ) { // Attach pending classifier check - may auto-approve before user responds appState = context.getAppState() return { behavior: 'ask', message: createPermissionRequestMessage(BashTool.name, { type: 'other', reason: safetyResult.message ?? 'Command contains patterns that require approval', }), decisionReason: { type: 'other', reason: safetyResult.message ?? 'Command contains patterns that require approval', }, ...(feature('BASH_CLASSIFIER') ? { pendingClassifierCheck: buildPendingClassifierCheck( input.command, appState.toolPermissionContext, ), } : {}), } } appState = context.getAppState() // SECURITY: Compute compoundCommandHasCd from the full command, NOT // hardcode false. The pipe-handling path previously passed `false` here, // disabling the cd+redirect check at pathValidation.ts:821. Appending // `| echo done` to `cd .claude && echo x > settings.json` routed through // this path with compoundCommandHasCd=false, letting the redirect write // to .claude/settings.json without the cd+redirect block firing. const pathResult = checkPathConstraints( input, getCwd(), appState.toolPermissionContext, commandHasAnyCd(input.command), astRedirects, astCommands, ) if (pathResult.behavior !== 'passthrough') { return pathResult } } // When pipe segments return 'ask' (individual segments not allowed by rules), // attach pending classifier check - may auto-approve before user responds. if (commandOperatorResult.behavior === 'ask') { appState = context.getAppState() return { ...commandOperatorResult, ...(feature('BASH_CLASSIFIER') ? { pendingClassifierCheck: buildPendingClassifierCheck( input.command, appState.toolPermissionContext, ), } : {}), } } return commandOperatorResult } // SECURITY: Legacy misparsing gate. Only runs when the tree-sitter module // is not loaded. Timeout/abort is fail-closed via too-complex (returned // early above), not routed here. When the AST parse succeeded, // astSubcommands is non-null and we've already validated structure; this // block is skipped entirely. The AST's 'too-complex' result subsumes // everything isBashSecurityCheckForMisparsing covered — both answer the // same question: "can splitCommand be trusted on this input?" if ( astSubcommands === null && !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_COMMAND_INJECTION_CHECK) ) { const originalCommandSafetyResult = await bashCommandIsSafeAsync( input.command, ) if ( originalCommandSafetyResult.behavior === 'ask' && originalCommandSafetyResult.isBashSecurityCheckForMisparsing ) { // Compound commands with safe heredoc patterns ($(cat <<'EOF'...EOF)) // trigger the $() check on the unsplit command. Strip the safe heredocs // and re-check the remainder — if other misparsing patterns exist // (e.g. backslash-escaped operators), they must still block. const remainder = stripSafeHeredocSubstitutions(input.command) const remainderResult = remainder !== null ? await bashCommandIsSafeAsync(remainder) : null if ( remainder === null || (remainderResult?.behavior === 'ask' && remainderResult.isBashSecurityCheckForMisparsing) ) { // Allow if the exact command has an explicit allow permission — the user // made a conscious choice to permit this specific command. appState = context.getAppState() const exactMatchResult = bashToolCheckExactMatchPermission( input, appState.toolPermissionContext, ) if (exactMatchResult.behavior === 'allow') { return exactMatchResult } // Attach pending classifier check - may auto-approve before user responds const decisionReason: PermissionDecisionReason = { type: 'other' as const, reason: originalCommandSafetyResult.message, } return { behavior: 'ask', message: createPermissionRequestMessage( BashTool.name, decisionReason, ), decisionReason, suggestions: [], // Don't suggest saving a potentially dangerous command ...(feature('BASH_CLASSIFIER') ? { pendingClassifierCheck: buildPendingClassifierCheck( input.command, appState.toolPermissionContext, ), } : {}), } } } } // Split into subcommands. Prefer the AST-extracted spans; fall back to // splitCommand only when tree-sitter was unavailable. The cd-cwd filter // strips the `cd ${cwd}` prefix that models like to prepend. const cwd = getCwd() const cwdMingw = getPlatform() === 'windows' ? windowsPathToPosixPath(cwd) : cwd const rawSubcommands = astSubcommands ?? shadowLegacySubs ?? splitCommand(input.command) const { subcommands, astCommandsByIdx } = filterCdCwdSubcommands( rawSubcommands, astCommands, cwd, cwdMingw, ) // CC-643: Cap subcommand fanout. Only the legacy splitCommand path can // explode — the AST path returns a bounded list (astSubcommands !== null) // or short-circuits to 'too-complex' for structures it can't represent. if ( astSubcommands === null && subcommands.length > MAX_SUBCOMMANDS_FOR_SECURITY_CHECK ) { logForDebugging( `bashPermissions: ${subcommands.length} subcommands exceeds cap (${MAX_SUBCOMMANDS_FOR_SECURITY_CHECK}) — returning ask`, { level: 'debug' }, ) const decisionReason = { type: 'other' as const, reason: `Command splits into ${subcommands.length} subcommands, too many to safety-check individually`, } return { behavior: 'ask', message: createPermissionRequestMessage(BashTool.name, decisionReason), decisionReason, } } // Ask if there are multiple `cd` commands const cdCommands = subcommands.filter(subCommand => isNormalizedCdCommand(subCommand), ) if (cdCommands.length > 1) { const decisionReason = { type: 'other' as const, reason: 'Multiple directory changes in one command require approval for clarity', } return { behavior: 'ask', decisionReason, message: createPermissionRequestMessage(BashTool.name, decisionReason), } } // Track if compound command contains cd for security validation // This prevents bypassing path checks via: cd .claude/ && mv test.txt settings.json const compoundCommandHasCd = cdCommands.length > 0 // SECURITY: Block compound commands that have both cd AND git // This prevents sandbox escape via: cd /malicious/dir && git status // where the malicious directory contains a bare git repo with core.fsmonitor. // This check must happen HERE (before subcommand-level permission checks) // because bashToolCheckPermission checks each subcommand independently via // BashTool.isReadOnly(), which would re-derive compoundCommandHasCd=false // from just "git status" alone, bypassing the readOnlyValidation.ts check. if (compoundCommandHasCd) { const hasGitCommand = subcommands.some(cmd => isNormalizedGitCommand(cmd.trim()), ) if (hasGitCommand) { const decisionReason = { type: 'other' as const, reason: 'Compound commands with cd and git require approval to prevent bare repository attacks', } return { behavior: 'ask', decisionReason, message: createPermissionRequestMessage(BashTool.name, decisionReason), } } } appState = context.getAppState() // re-compute the latest in case the user hit shift+tab // SECURITY FIX: Check Bash deny/ask rules BEFORE path constraints // This ensures that explicit deny rules like Bash(ls:*) take precedence over // path constraint checks that return 'ask' for paths outside the project. // Without this ordering, absolute paths outside the project (e.g., ls /home) // would bypass deny rules because checkPathConstraints would return 'ask' first. // // Note: bashToolCheckPermission calls checkPathConstraints internally, which handles // output redirection validation on each subcommand. However, since splitCommand strips // redirections before we get here, we MUST validate output redirections on the ORIGINAL // command AFTER checking deny rules but BEFORE returning results. const subcommandPermissionDecisions = subcommands.map((command, i) => bashToolCheckPermission( { command }, appState.toolPermissionContext, compoundCommandHasCd, astCommandsByIdx[i], ), ) // Deny if any subcommands are denied const deniedSubresult = subcommandPermissionDecisions.find( _ => _.behavior === 'deny', ) if (deniedSubresult !== undefined) { return { behavior: 'deny', message: `Permission to use ${BashTool.name} with command ${input.command} has been denied.`, decisionReason: { type: 'subcommandResults', reasons: new Map( subcommandPermissionDecisions.map((result, i) => [ subcommands[i]!, result, ]), ), }, } } // Validate output redirections on the ORIGINAL command (before splitCommand stripped them) // This must happen AFTER checking deny rules but BEFORE returning results. // Output redirections like "> /etc/passwd" are stripped by splitCommand, so the per-subcommand // checkPathConstraints calls won't see them. We validate them here on the original input. // SECURITY: When AST data is available, pass AST-derived redirects so // checkPathConstraints uses them directly instead of re-parsing with // shell-quote (which has a known single-quote backslash misparsing bug // that can silently hide redirect operators). const pathResult = checkPathConstraints( input, getCwd(), appState.toolPermissionContext, compoundCommandHasCd, astRedirects, astCommands, ) if (pathResult.behavior === 'deny') { return pathResult } const askSubresult = subcommandPermissionDecisions.find( _ => _.behavior === 'ask', ) const nonAllowCount = count( subcommandPermissionDecisions, _ => _.behavior !== 'allow', ) // SECURITY (GH#28784): Only short-circuit on a path-constraint 'ask' when no // subcommand independently produced an 'ask'. checkPathConstraints re-runs the // path-command loop on the full input, so `cd && python3 foo.py` // produces an ask with ONLY a Read(/**) suggestion — the UI renders it as // "Yes, allow reading from /" and picking that option silently approves // python3. When a subcommand has its own ask (e.g. the cd subcommand's own // path-constraint ask), fall through: either the askSubresult short-circuit // below fires (single non-allow subcommand) or the merge flow collects Bash // rule suggestions for every non-allow subcommand. The per-subcommand // checkPathConstraints call inside bashToolCheckPermission already captures // the Read rule for the cd target in that path. // // When no subcommand asked (all allow, or all passthrough like `printf > file`), // pathResult IS the only ask — return it so redirection checks surface. if (pathResult.behavior === 'ask' && askSubresult === undefined) { return pathResult } // Ask if any subcommands require approval (e.g., ls/cd outside boundaries). // Only short-circuit when exactly ONE subcommand needs approval — if multiple // do (e.g. cd-outside-project ask + python3 passthrough), fall through to the // merge flow so the prompt surfaces Bash rule suggestions for all of them // instead of only the first ask's Read rule (GH#28784). if (askSubresult !== undefined && nonAllowCount === 1) { return { ...askSubresult, ...(feature('BASH_CLASSIFIER') ? { pendingClassifierCheck: buildPendingClassifierCheck( input.command, appState.toolPermissionContext, ), } : {}), } } // Allow if exact command was allowed if (exactMatchResult.behavior === 'allow') { return exactMatchResult } // If all subcommands are allowed via exact or prefix match, allow the // command — but only if no command injection is possible. When the AST // parse succeeded, each subcommand is already known-safe (no hidden // substitutions, no structural tricks); the per-subcommand re-check is // redundant. When on the legacy path, re-run bashCommandIsSafeAsync per sub. let hasPossibleCommandInjection = false if ( astSubcommands === null && !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_COMMAND_INJECTION_CHECK) ) { // CC-643: Batch divergence telemetry into a single logEvent. The per-sub // logEvent was the hot-path syscall driver (each call → /proc/self/stat // via process.memoryUsage()). Aggregate count preserves the signal. let divergenceCount = 0 const onDivergence = () => { divergenceCount++ } const results = await Promise.all( subcommands.map(c => bashCommandIsSafeAsync(c, onDivergence)), ) hasPossibleCommandInjection = results.some( r => r.behavior !== 'passthrough', ) if (divergenceCount > 0) { logEvent('tengu_tree_sitter_security_divergence', { quoteContextDivergence: true, count: divergenceCount, }) } } if ( subcommandPermissionDecisions.every(_ => _.behavior === 'allow') && !hasPossibleCommandInjection ) { return { behavior: 'allow', updatedInput: input, decisionReason: { type: 'subcommandResults', reasons: new Map( subcommandPermissionDecisions.map((result, i) => [ subcommands[i]!, result, ]), ), }, } } // Query Haiku for command prefixes // Skip the Haiku call — the UI computes the prefix locally and // lets the user edit it. Still call when a custom fn is injected (tests). let commandSubcommandPrefix: Awaited< ReturnType > = null if (getCommandSubcommandPrefixFn !== getCommandSubcommandPrefix) { commandSubcommandPrefix = await getCommandSubcommandPrefixFn( input.command, context.abortController.signal, context.options.isNonInteractiveSession, ) if (context.abortController.signal.aborted) { throw new AbortError() } } // If there is only one command, no need to process subcommands appState = context.getAppState() // re-compute the latest in case the user hit shift+tab if (subcommands.length === 1) { const result = await checkCommandAndSuggestRules( { command: subcommands[0]! }, appState.toolPermissionContext, commandSubcommandPrefix, compoundCommandHasCd, astSubcommands !== null, ) // If command wasn't allowed, attach pending classifier check. // At this point, 'ask' can only come from bashCommandIsSafe (security check inside // checkCommandAndSuggestRules), NOT from explicit ask rules - those were already // filtered out at step 13 (askSubresult check). The classifier can bypass security. if (result.behavior === 'ask' || result.behavior === 'passthrough') { return { ...result, ...(feature('BASH_CLASSIFIER') ? { pendingClassifierCheck: buildPendingClassifierCheck( input.command, appState.toolPermissionContext, ), } : {}), } } return result } // Check subcommand permission results const subcommandResults: Map = new Map() for (const subcommand of subcommands) { subcommandResults.set( subcommand, await checkCommandAndSuggestRules( { // Pass through input params like `sandbox` ...input, command: subcommand, }, appState.toolPermissionContext, commandSubcommandPrefix?.subcommandPrefixes.get(subcommand), compoundCommandHasCd, astSubcommands !== null, ), ) } // Allow if all subcommands are allowed // Note that this is different than 6b because we are checking the command injection results. if ( subcommands.every(subcommand => { const permissionResult = subcommandResults.get(subcommand) return permissionResult?.behavior === 'allow' }) ) { // Keep subcommandResults as PermissionResult for decisionReason return { behavior: 'allow', updatedInput: input, decisionReason: { type: 'subcommandResults', reasons: subcommandResults, }, } } // Otherwise, ask for permission const collectedRules: Map = new Map() for (const [subcommand, permissionResult] of subcommandResults) { if ( permissionResult.behavior === 'ask' || permissionResult.behavior === 'passthrough' ) { const updates = 'suggestions' in permissionResult ? permissionResult.suggestions : undefined const rules = extractRules(updates) for (const rule of rules) { // Use string representation as key for deduplication const ruleKey = permissionRuleValueToString(rule) collectedRules.set(ruleKey, rule) } // GH#28784 follow-up: security-check asks (compound-cd+write, process // substitution, etc.) carry no suggestions. In a compound command like // `cd ~/out && rm -rf x`, that means only cd's Read rule gets collected // and the UI labels the prompt "Yes, allow reading from /" — never // mentioning rm. Synthesize a Bash(exact) rule so the UI shows the // chained command. Skip explicit ask rules (decisionReason.type 'rule') // where the user deliberately wants to review each time. if ( permissionResult.behavior === 'ask' && rules.length === 0 && permissionResult.decisionReason?.type !== 'rule' ) { for (const rule of extractRules( suggestionForExactCommand(subcommand), )) { const ruleKey = permissionRuleValueToString(rule) collectedRules.set(ruleKey, rule) } } // Note: We only collect rules, not other update types like mode changes // This is appropriate for bash subcommands which primarily need rule suggestions } } const decisionReason = { type: 'subcommandResults' as const, reasons: subcommandResults, } // GH#11380: Cap at MAX_SUGGESTED_RULES_FOR_COMPOUND. Map preserves insertion // order (subcommand order), so slicing keeps the leftmost N. const cappedRules = Array.from(collectedRules.values()).slice( 0, MAX_SUGGESTED_RULES_FOR_COMPOUND, ) const suggestedUpdates: PermissionUpdate[] | undefined = cappedRules.length > 0 ? [ { type: 'addRules', rules: cappedRules, behavior: 'allow', destination: 'localSettings', }, ] : undefined // Attach pending classifier check - may auto-approve before user responds. // Behavior is 'ask' if any subcommand was 'ask' (e.g., path constraint or ask // rule) — before the GH#28784 fix, ask subresults always short-circuited above // so this path only saw 'passthrough' subcommands and hardcoded that. return { behavior: askSubresult !== undefined ? 'ask' : 'passthrough', message: createPermissionRequestMessage(BashTool.name, decisionReason), decisionReason, suggestions: suggestedUpdates, ...(feature('BASH_CLASSIFIER') ? { pendingClassifierCheck: buildPendingClassifierCheck( input.command, appState.toolPermissionContext, ), } : {}), } } /** * Checks if a subcommand is a git command after normalizing away safe wrappers * (env vars, timeout, etc.) and shell quotes. * * SECURITY: Must normalize before matching to prevent bypasses like: * 'git' status — shell quotes hide the command from a naive regex * NO_COLOR=1 git status — env var prefix hides the command */ export function isNormalizedGitCommand(command: string): boolean { // Fast path: catch the most common case before any parsing if (command.startsWith('git ') || command === 'git') { return true } const stripped = stripSafeWrappers(command) const parsed = tryParseShellCommand(stripped) if (parsed.success && parsed.tokens.length > 0) { // Direct git command if (parsed.tokens[0] === 'git') { return true } // "xargs git ..." — xargs runs git in the current directory, // so it must be treated as a git command for cd+git security checks. // This matches the xargs prefix handling in filterRulesByContentsMatchingInput. if (parsed.tokens[0] === 'xargs' && parsed.tokens.includes('git')) { return true } return false } return /^git(?:\s|$)/.test(stripped) } /** * Checks if a subcommand is a cd command after normalizing away safe wrappers * (env vars, timeout, etc.) and shell quotes. * * SECURITY: Must normalize before matching to prevent bypasses like: * FORCE_COLOR=1 cd sub — env var prefix hides the cd from a naive /^cd / regex * This mirrors isNormalizedGitCommand to ensure symmetric normalization. * * Also matches pushd/popd — they change cwd just like cd, so * pushd /tmp/bare-repo && git status * must trigger the same cd+git guard. Mirrors PowerShell's * DIRECTORY_CHANGE_ALIASES (src/utils/powershell/parser.ts). */ export function isNormalizedCdCommand(command: string): boolean { const stripped = stripSafeWrappers(command) const parsed = tryParseShellCommand(stripped) if (parsed.success && parsed.tokens.length > 0) { const cmd = parsed.tokens[0] return cmd === 'cd' || cmd === 'pushd' || cmd === 'popd' } return /^(?:cd|pushd|popd)(?:\s|$)/.test(stripped) } /** * Checks if a compound command contains any cd command, * using normalized detection that handles env var prefixes and shell quotes. */ export function commandHasAnyCd(command: string): boolean { return splitCommand(command).some(subcmd => isNormalizedCdCommand(subcmd.trim()), ) }