source dump of claude code
at main 2621 lines 99 kB view raw
1import { feature } from 'bun:bundle' 2import { APIUserAbortError } from '@anthropic-ai/sdk' 3import type { z } from 'zod/v4' 4import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 5import { 6 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 7 logEvent, 8} from '../../services/analytics/index.js' 9import type { ToolPermissionContext, ToolUseContext } from '../../Tool.js' 10import type { PendingClassifierCheck } from '../../types/permissions.js' 11import { count } from '../../utils/array.js' 12import { 13 checkSemantics, 14 nodeTypeId, 15 type ParseForSecurityResult, 16 parseForSecurityFromAst, 17 type Redirect, 18 type SimpleCommand, 19} from '../../utils/bash/ast.js' 20import { 21 type CommandPrefixResult, 22 extractOutputRedirections, 23 getCommandSubcommandPrefix, 24 splitCommand_DEPRECATED, 25} from '../../utils/bash/commands.js' 26import { parseCommandRaw } from '../../utils/bash/parser.js' 27import { tryParseShellCommand } from '../../utils/bash/shellQuote.js' 28import { getCwd } from '../../utils/cwd.js' 29import { logForDebugging } from '../../utils/debug.js' 30import { isEnvTruthy } from '../../utils/envUtils.js' 31import { AbortError } from '../../utils/errors.js' 32import type { 33 ClassifierBehavior, 34 ClassifierResult, 35} from '../../utils/permissions/bashClassifier.js' 36import { 37 classifyBashCommand, 38 getBashPromptAllowDescriptions, 39 getBashPromptAskDescriptions, 40 getBashPromptDenyDescriptions, 41 isClassifierPermissionsEnabled, 42} from '../../utils/permissions/bashClassifier.js' 43import type { 44 PermissionDecisionReason, 45 PermissionResult, 46} from '../../utils/permissions/PermissionResult.js' 47import type { 48 PermissionRule, 49 PermissionRuleValue, 50} from '../../utils/permissions/PermissionRule.js' 51import { extractRules } from '../../utils/permissions/PermissionUpdate.js' 52import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' 53import { permissionRuleValueToString } from '../../utils/permissions/permissionRuleParser.js' 54import { 55 createPermissionRequestMessage, 56 getRuleByContentsForTool, 57} from '../../utils/permissions/permissions.js' 58import { 59 parsePermissionRule, 60 type ShellPermissionRule, 61 matchWildcardPattern as sharedMatchWildcardPattern, 62 permissionRuleExtractPrefix as sharedPermissionRuleExtractPrefix, 63 suggestionForExactCommand as sharedSuggestionForExactCommand, 64 suggestionForPrefix as sharedSuggestionForPrefix, 65} from '../../utils/permissions/shellRuleMatching.js' 66import { getPlatform } from '../../utils/platform.js' 67import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' 68import { jsonStringify } from '../../utils/slowOperations.js' 69import { windowsPathToPosixPath } from '../../utils/windowsPaths.js' 70import { BashTool } from './BashTool.js' 71import { checkCommandOperatorPermissions } from './bashCommandHelpers.js' 72import { 73 bashCommandIsSafeAsync_DEPRECATED, 74 stripSafeHeredocSubstitutions, 75} from './bashSecurity.js' 76import { checkPermissionMode } from './modeValidation.js' 77import { checkPathConstraints } from './pathValidation.js' 78import { checkSedConstraints } from './sedValidation.js' 79import { shouldUseSandbox } from './shouldUseSandbox.js' 80 81// DCE cliff: Bun's feature() evaluator has a per-function complexity budget. 82// bashToolHasPermission is right at the limit. `import { X as Y }` aliases 83// inside the import block count toward this budget; when they push it over 84// the threshold Bun can no longer prove feature('BASH_CLASSIFIER') is a 85// constant and silently evaluates the ternaries to `false`, dropping every 86// pendingClassifierCheck spread. Keep aliases as top-level const rebindings 87// instead. (See also the comment on checkSemanticsDeny below.) 88const bashCommandIsSafeAsync = bashCommandIsSafeAsync_DEPRECATED 89const splitCommand = splitCommand_DEPRECATED 90 91// Env-var assignment prefix (VAR=value). Shared across three while-loops that 92// skip safe env vars before extracting the command name. 93const ENV_VAR_ASSIGN_RE = /^[A-Za-z_]\w*=/ 94 95// CC-643: On complex compound commands, splitCommand_DEPRECATED can produce a 96// very large subcommands array (possible exponential growth; #21405's ReDoS fix 97// may have been incomplete). Each subcommand then runs tree-sitter parse + 98// ~20 validators + logEvent (bashSecurity.ts), and with memoized metadata the 99// resulting microtask chain starves the event loop — REPL freeze at 100% CPU, 100// strace showed /proc/self/stat reads at ~127Hz with no epoll_wait. Fifty is 101// generous: legitimate user commands don't split that wide. Above the cap we 102// fall back to 'ask' (safe default — we can't prove safety, so we prompt). 103export const MAX_SUBCOMMANDS_FOR_SECURITY_CHECK = 50 104 105// GH#11380: Cap the number of per-subcommand rules suggested for compound 106// commands. Beyond this, the "Yes, and don't ask again for X, Y, Z…" label 107// degrades to "similar commands" anyway, and saving 10+ rules from one prompt 108// is more likely noise than intent. Users chaining this many write commands 109// in one && list are rare; they can always approve once and add rules manually. 110export const MAX_SUGGESTED_RULES_FOR_COMPOUND = 5 111 112/** 113 * [ANT-ONLY] Log classifier evaluation results for analysis. 114 * This helps us understand which classifier rules are being evaluated 115 * and how the classifier is deciding on commands. 116 */ 117function logClassifierResultForAnts( 118 command: string, 119 behavior: ClassifierBehavior, 120 descriptions: string[], 121 result: ClassifierResult, 122): void { 123 if (process.env.USER_TYPE !== 'ant') { 124 return 125 } 126 127 logEvent('tengu_internal_bash_classifier_result', { 128 behavior: 129 behavior as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 130 descriptions: jsonStringify( 131 descriptions, 132 ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 133 matches: result.matches, 134 matchedDescription: (result.matchedDescription ?? 135 '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 136 confidence: 137 result.confidence as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 138 reason: 139 result.reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 140 // Note: command contains code/filepaths - this is ANT-ONLY so it's OK 141 command: 142 command as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 143 }) 144} 145 146/** 147 * Extract a stable command prefix (command + subcommand) from a raw command string. 148 * Skips leading env var assignments only if they are in SAFE_ENV_VARS (or 149 * ANT_ONLY_SAFE_ENV_VARS for ant users). Returns null if a non-safe env var is 150 * encountered (to fall back to exact match), or if the second token doesn't look 151 * like a subcommand (lowercase alphanumeric, e.g., "commit", "run"). 152 * 153 * Examples: 154 * 'git commit -m "fix typo"' → 'git commit' 155 * 'NODE_ENV=prod npm run build' → 'npm run' (NODE_ENV is safe) 156 * 'MY_VAR=val npm run build' → null (MY_VAR is not safe) 157 * 'ls -la' → null (flag, not a subcommand) 158 * 'cat file.txt' → null (filename, not a subcommand) 159 * 'chmod 755 file' → null (number, not a subcommand) 160 */ 161export function getSimpleCommandPrefix(command: string): string | null { 162 const tokens = command.trim().split(/\s+/).filter(Boolean) 163 if (tokens.length === 0) return null 164 165 // Skip env var assignments (VAR=value) at the start, but only if they are 166 // in SAFE_ENV_VARS (or ANT_ONLY_SAFE_ENV_VARS for ant users). If a non-safe 167 // env var is encountered, return null to fall back to exact match. This 168 // prevents generating prefix rules like Bash(npm run:*) that can never match 169 // at allow-rule check time, because stripSafeWrappers only strips safe vars. 170 let i = 0 171 while (i < tokens.length && ENV_VAR_ASSIGN_RE.test(tokens[i]!)) { 172 const varName = tokens[i]!.split('=')[0]! 173 const isAntOnlySafe = 174 process.env.USER_TYPE === 'ant' && ANT_ONLY_SAFE_ENV_VARS.has(varName) 175 if (!SAFE_ENV_VARS.has(varName) && !isAntOnlySafe) { 176 return null 177 } 178 i++ 179 } 180 181 const remaining = tokens.slice(i) 182 if (remaining.length < 2) return null 183 const subcmd = remaining[1]! 184 // Second token must look like a subcommand (e.g., "commit", "run", "compose"), 185 // not a flag (-rf), filename (file.txt), path (/tmp), URL, or number (755). 186 if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(subcmd)) return null 187 return remaining.slice(0, 2).join(' ') 188} 189 190// Bare-prefix suggestions like `bash:*` or `sh:*` would allow arbitrary code 191// via `-c`. Wrapper suggestions like `env:*` or `sudo:*` would do the same: 192// `env` is NOT in SAFE_WRAPPER_PATTERNS, so `env bash -c "evil"` survives 193// stripSafeWrappers unchanged and hits the startsWith("env ") check at 194// the prefix-rule matcher. Shell list mirrors DANGEROUS_SHELL_PREFIXES in 195// src/utils/shell/prefix.ts which guarded the old Haiku extractor. 196const BARE_SHELL_PREFIXES = new Set([ 197 'sh', 198 'bash', 199 'zsh', 200 'fish', 201 'csh', 202 'tcsh', 203 'ksh', 204 'dash', 205 'cmd', 206 'powershell', 207 'pwsh', 208 // wrappers that exec their args as a command 209 'env', 210 'xargs', 211 // SECURITY: checkSemantics (ast.ts) strips these wrappers to check the 212 // wrapped command. Suggesting `Bash(nice:*)` would be ≈ `Bash(*)` — users 213 // would add it after a prompt, then `nice rm -rf /` passes semantics while 214 // deny/cd+git gates see 'nice' (SAFE_WRAPPER_PATTERNS below didn't strip 215 // bare `nice` until this fix). Block these from ever being suggested. 216 'nice', 217 'stdbuf', 218 'nohup', 219 'timeout', 220 'time', 221 // privilege escalation — sudo:* from `sudo -u foo ...` would auto-approve 222 // any future sudo invocation 223 'sudo', 224 'doas', 225 'pkexec', 226]) 227 228/** 229 * UI-only fallback: extract the first word alone when getSimpleCommandPrefix 230 * declines. In external builds TREE_SITTER_BASH is off, so the async 231 * tree-sitter refinement in BashPermissionRequest never fires — without this, 232 * pipes and compounds (`python3 file.py 2>&1 | tail -20`) dump into the 233 * editable field verbatim. 234 * 235 * Deliberately not used by suggestionForExactCommand: a backend-suggested 236 * `Bash(rm:*)` is too broad to auto-generate, but as an editable starting 237 * point it's what users expect (Slack C07VBSHV7EV/p1772670433193449). 238 * 239 * Reuses the same SAFE_ENV_VARS gate as getSimpleCommandPrefix — a rule like 240 * `Bash(python3:*)` can never match `RUN=/path python3 ...` at check time 241 * because stripSafeWrappers won't strip RUN. 242 */ 243export function getFirstWordPrefix(command: string): string | null { 244 const tokens = command.trim().split(/\s+/).filter(Boolean) 245 246 let i = 0 247 while (i < tokens.length && ENV_VAR_ASSIGN_RE.test(tokens[i]!)) { 248 const varName = tokens[i]!.split('=')[0]! 249 const isAntOnlySafe = 250 process.env.USER_TYPE === 'ant' && ANT_ONLY_SAFE_ENV_VARS.has(varName) 251 if (!SAFE_ENV_VARS.has(varName) && !isAntOnlySafe) { 252 return null 253 } 254 i++ 255 } 256 257 const cmd = tokens[i] 258 if (!cmd) return null 259 // Same shape check as the subcommand regex in getSimpleCommandPrefix: 260 // rejects paths (./script.sh, /usr/bin/python), flags, numbers, filenames. 261 if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(cmd)) return null 262 if (BARE_SHELL_PREFIXES.has(cmd)) return null 263 return cmd 264} 265 266function suggestionForExactCommand(command: string): PermissionUpdate[] { 267 // Heredoc commands contain multi-line content that changes each invocation, 268 // making exact-match rules useless (they'll never match again). Extract a 269 // stable prefix before the heredoc operator and suggest a prefix rule instead. 270 const heredocPrefix = extractPrefixBeforeHeredoc(command) 271 if (heredocPrefix) { 272 return sharedSuggestionForPrefix(BashTool.name, heredocPrefix) 273 } 274 275 // Multiline commands without heredoc also make poor exact-match rules. 276 // Saving the full multiline text can produce patterns containing `:*` in 277 // the middle, which fails permission validation and corrupts the settings 278 // file. Use the first line as a prefix rule instead. 279 if (command.includes('\n')) { 280 const firstLine = command.split('\n')[0]!.trim() 281 if (firstLine) { 282 return sharedSuggestionForPrefix(BashTool.name, firstLine) 283 } 284 } 285 286 // Single-line commands: extract a 2-word prefix for reusable rules. 287 // Without this, exact-match rules are saved that never match future 288 // invocations with different arguments. 289 const prefix = getSimpleCommandPrefix(command) 290 if (prefix) { 291 return sharedSuggestionForPrefix(BashTool.name, prefix) 292 } 293 294 return sharedSuggestionForExactCommand(BashTool.name, command) 295} 296 297/** 298 * If the command contains a heredoc (<<), extract the command prefix before it. 299 * Returns the first word(s) before the heredoc operator as a stable prefix, 300 * or null if the command doesn't contain a heredoc. 301 * 302 * Examples: 303 * 'git commit -m "$(cat <<\'EOF\'\n...\nEOF\n)"' → 'git commit' 304 * 'cat <<EOF\nhello\nEOF' → 'cat' 305 * 'echo hello' → null (no heredoc) 306 */ 307function extractPrefixBeforeHeredoc(command: string): string | null { 308 if (!command.includes('<<')) return null 309 310 const idx = command.indexOf('<<') 311 if (idx <= 0) return null 312 313 const before = command.substring(0, idx).trim() 314 if (!before) return null 315 316 const prefix = getSimpleCommandPrefix(before) 317 if (prefix) return prefix 318 319 // Fallback: skip safe env var assignments and take up to 2 tokens. 320 // This preserves flag tokens (e.g., "python3 -c" stays "python3 -c", 321 // not just "python3") and skips safe env var prefixes like "NODE_ENV=test". 322 // If a non-safe env var is encountered, return null to avoid generating 323 // prefix rules that can never match (same rationale as getSimpleCommandPrefix). 324 const tokens = before.split(/\s+/).filter(Boolean) 325 let i = 0 326 while (i < tokens.length && ENV_VAR_ASSIGN_RE.test(tokens[i]!)) { 327 const varName = tokens[i]!.split('=')[0]! 328 const isAntOnlySafe = 329 process.env.USER_TYPE === 'ant' && ANT_ONLY_SAFE_ENV_VARS.has(varName) 330 if (!SAFE_ENV_VARS.has(varName) && !isAntOnlySafe) { 331 return null 332 } 333 i++ 334 } 335 if (i >= tokens.length) return null 336 return tokens.slice(i, i + 2).join(' ') || null 337} 338 339function suggestionForPrefix(prefix: string): PermissionUpdate[] { 340 return sharedSuggestionForPrefix(BashTool.name, prefix) 341} 342 343/** 344 * Extract prefix from legacy :* syntax (e.g., "npm:*" -> "npm") 345 * Delegates to shared implementation. 346 */ 347export const permissionRuleExtractPrefix = sharedPermissionRuleExtractPrefix 348 349/** 350 * Match a command against a wildcard pattern (case-sensitive for Bash). 351 * Delegates to shared implementation. 352 */ 353export function matchWildcardPattern( 354 pattern: string, 355 command: string, 356): boolean { 357 return sharedMatchWildcardPattern(pattern, command) 358} 359 360/** 361 * Parse a permission rule into a structured rule object. 362 * Delegates to shared implementation. 363 */ 364export const bashPermissionRule: ( 365 permissionRule: string, 366) => ShellPermissionRule = parsePermissionRule 367 368/** 369 * Whitelist of environment variables that are safe to strip from commands. 370 * These variables CANNOT execute code or load libraries. 371 * 372 * SECURITY: These must NEVER be added to the whitelist: 373 * - PATH, LD_PRELOAD, LD_LIBRARY_PATH, DYLD_* (execution/library loading) 374 * - PYTHONPATH, NODE_PATH, CLASSPATH, RUBYLIB (module loading) 375 * - GOFLAGS, RUSTFLAGS, NODE_OPTIONS (can contain code execution flags) 376 * - HOME, TMPDIR, SHELL, BASH_ENV (affect system behavior) 377 */ 378const SAFE_ENV_VARS = new Set([ 379 // Go - build/runtime settings only 380 'GOEXPERIMENT', // experimental features 381 'GOOS', // target OS 382 'GOARCH', // target architecture 383 'CGO_ENABLED', // enable/disable CGO 384 'GO111MODULE', // module mode 385 386 // Rust - logging/debugging only 387 'RUST_BACKTRACE', // backtrace verbosity 388 'RUST_LOG', // logging filter 389 390 // Node - environment name only (not NODE_OPTIONS!) 391 'NODE_ENV', 392 393 // Python - behavior flags only (not PYTHONPATH!) 394 'PYTHONUNBUFFERED', // disable buffering 395 'PYTHONDONTWRITEBYTECODE', // no .pyc files 396 397 // Pytest - test configuration 398 'PYTEST_DISABLE_PLUGIN_AUTOLOAD', // disable plugin loading 399 'PYTEST_DEBUG', // debug output 400 401 // API keys and authentication 402 'ANTHROPIC_API_KEY', // API authentication 403 404 // Locale and character encoding 405 'LANG', // default locale 406 'LANGUAGE', // language preference list 407 'LC_ALL', // override all locale settings 408 'LC_CTYPE', // character classification 409 'LC_TIME', // time format 410 'CHARSET', // character set preference 411 412 // Terminal and display 413 'TERM', // terminal type 414 'COLORTERM', // color terminal indicator 415 'NO_COLOR', // disable color output (universal standard) 416 'FORCE_COLOR', // force color output 417 'TZ', // timezone 418 419 // Color configuration for various tools 420 'LS_COLORS', // colors for ls (GNU) 421 'LSCOLORS', // colors for ls (BSD/macOS) 422 'GREP_COLOR', // grep match color (deprecated) 423 'GREP_COLORS', // grep color scheme 424 'GCC_COLORS', // GCC diagnostic colors 425 426 // Display formatting 427 'TIME_STYLE', // time display format for ls 428 'BLOCK_SIZE', // block size for du/df 429 'BLOCKSIZE', // alternative block size 430]) 431 432/** 433 * ANT-ONLY environment variables that are safe to strip from commands. 434 * These are only enabled when USER_TYPE === 'ant'. 435 * 436 * SECURITY: These env vars are stripped before permission-rule matching, which 437 * means `DOCKER_HOST=tcp://evil.com docker ps` matches a `Bash(docker ps:*)` 438 * rule after stripping. This is INTENTIONALLY ANT-ONLY (gated at line ~380) 439 * and MUST NEVER ship to external users. DOCKER_HOST redirects the Docker 440 * daemon endpoint — stripping it defeats prefix-based permission restrictions 441 * by hiding the network endpoint from the permission check. KUBECONFIG 442 * similarly controls which cluster kubectl talks to. These are convenience 443 * strippings for internal power users who accept the risk. 444 * 445 * Based on analysis of 30 days of tengu_internal_bash_tool_use_permission_request events. 446 */ 447const ANT_ONLY_SAFE_ENV_VARS = new Set([ 448 // Kubernetes and container config (config file pointers, not execution) 449 'KUBECONFIG', // kubectl config file path — controls which cluster kubectl uses 450 'DOCKER_HOST', // Docker daemon socket/endpoint — controls which daemon docker talks to 451 452 // Cloud provider project/profile selection (just names/identifiers) 453 'AWS_PROFILE', // AWS profile name selection 454 'CLOUDSDK_CORE_PROJECT', // GCP project ID 455 'CLUSTER', // generic cluster name 456 457 // Anthropic internal cluster selection (just names/identifiers) 458 'COO_CLUSTER', // coo cluster name 459 'COO_CLUSTER_NAME', // coo cluster name (alternate) 460 'COO_NAMESPACE', // coo namespace 461 'COO_LAUNCH_YAML_DRY_RUN', // dry run mode 462 463 // Feature flags (boolean/string flags only) 464 'SKIP_NODE_VERSION_CHECK', // skip version check 465 'EXPECTTEST_ACCEPT', // accept test expectations 466 'CI', // CI environment indicator 467 'GIT_LFS_SKIP_SMUDGE', // skip LFS downloads 468 469 // GPU/Device selection (just device IDs) 470 'CUDA_VISIBLE_DEVICES', // GPU device selection 471 'JAX_PLATFORMS', // JAX platform selection 472 473 // Display/terminal settings 474 'COLUMNS', // terminal width 475 'TMUX', // TMUX socket info 476 477 // Test/debug configuration 478 'POSTGRESQL_VERSION', // postgres version string 479 'FIRESTORE_EMULATOR_HOST', // emulator host:port 480 'HARNESS_QUIET', // quiet mode flag 481 'TEST_CROSSCHECK_LISTS_MATCH_UPDATE', // test update flag 482 'DBT_PER_DEVELOPER_ENVIRONMENTS', // DBT config 483 'STATSIG_FORD_DB_CHECKS', // statsig DB check flag 484 485 // Build configuration 486 'ANT_ENVIRONMENT', // Anthropic environment name 487 'ANT_SERVICE', // Anthropic service name 488 'MONOREPO_ROOT_DIR', // monorepo root path 489 490 // Version selectors 491 'PYENV_VERSION', // Python version selection 492 493 // Credentials (approved subset - these don't change exfil risk) 494 'PGPASSWORD', // Postgres password 495 'GH_TOKEN', // GitHub token 496 'GROWTHBOOK_API_KEY', // self-hosted growthbook 497]) 498 499/** 500 * Strips full-line comments from a command. 501 * This handles cases where Claude adds comments in bash commands, e.g.: 502 * "# Check the logs directory\nls /home/user/logs" 503 * Should be stripped to: "ls /home/user/logs" 504 * 505 * Only strips full-line comments (lines where the entire line is a comment), 506 * not inline comments that appear after a command on the same line. 507 */ 508function stripCommentLines(command: string): string { 509 const lines = command.split('\n') 510 const nonCommentLines = lines.filter(line => { 511 const trimmed = line.trim() 512 // Keep lines that are not empty and don't start with # 513 return trimmed !== '' && !trimmed.startsWith('#') 514 }) 515 516 // If all lines were comments/empty, return original 517 if (nonCommentLines.length === 0) { 518 return command 519 } 520 521 return nonCommentLines.join('\n') 522} 523 524export function stripSafeWrappers(command: string): string { 525 // SECURITY: Use [ \t]+ not \s+ — \s matches \n/\r which are command 526 // separators in bash. Matching across a newline would strip the wrapper from 527 // one line and leave a different command on the next line for bash to execute. 528 // 529 // SECURITY: `(?:--[ \t]+)?` consumes the wrapper's own `--` so 530 // `nohup -- rm -- -/../foo` strips to `rm -- -/../foo` (not `-- rm ...` 531 // which would skip path validation with `--` as an unknown baseCmd). 532 const SAFE_WRAPPER_PATTERNS = [ 533 // timeout: enumerate GNU long flags — no-value (--foreground, 534 // --preserve-status, --verbose), value-taking in both =fused and 535 // space-separated forms (--kill-after=5, --kill-after 5, --signal=TERM, 536 // --signal TERM). Short: -v (no-arg), -k/-s with separate or fused value. 537 // SECURITY: flag VALUES use allowlist [A-Za-z0-9_.+-] (signals are 538 // TERM/KILL/9, durations are 5/5s/10.5). Previously [^ \t]+ matched 539 // $ ( ) ` | ; & — `timeout -k$(id) 10 ls` stripped to `ls`, matched 540 // Bash(ls:*), while bash expanded $(id) during word splitting BEFORE 541 // timeout ran. Contrast ENV_VAR_PATTERN below which already allowlists. 542 /^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]+/, 543 /^time[ \t]+(?:--[ \t]+)?/, 544 // SECURITY: keep in sync with checkSemantics wrapper-strip (ast.ts 545 // ~:1990-2080) AND stripWrappersFromArgv (pathValidation.ts ~:1260). 546 // Previously this pattern REQUIRED `-n N`; checkSemantics already handled 547 // bare `nice` and legacy `-N`. Asymmetry meant checkSemantics exposed the 548 // wrapped command to semantic checks but deny-rule matching and the cd+git 549 // gate saw the wrapper name. `nice rm -rf /` with Bash(rm:*) deny became 550 // ask instead of deny; `cd evil && nice git status` skipped the bare-repo 551 // RCE gate. PR #21503 fixed stripWrappersFromArgv; this was missed. 552 // Now matches: `nice cmd`, `nice -n N cmd`, `nice -N cmd` (all forms 553 // checkSemantics strips). 554 /^nice(?:[ \t]+-n[ \t]+-?\d+|[ \t]+-\d+)?[ \t]+(?:--[ \t]+)?/, 555 // stdbuf: fused short flags only (-o0, -eL). checkSemantics handles more 556 // (space-separated, long --output=MODE), but we fail-closed on those 557 // above so not over-stripping here is safe. Main need: `stdbuf -o0 cmd`. 558 /^stdbuf(?:[ \t]+-[ioe][LN0-9]+)+[ \t]+(?:--[ \t]+)?/, 559 /^nohup[ \t]+(?:--[ \t]+)?/, 560 ] as const 561 562 // Pattern for environment variables: 563 // ^([A-Za-z_][A-Za-z0-9_]*) - Variable name (standard identifier) 564 // = - Equals sign 565 // ([A-Za-z0-9_./:-]+) - Value: alphanumeric + safe punctuation only 566 // [ \t]+ - Required HORIZONTAL whitespace after value 567 // 568 // SECURITY: Only matches unquoted values with safe characters (no $(), `, $var, ;|&). 569 // 570 // SECURITY: Trailing whitespace MUST be [ \t]+ (horizontal only), NOT \s+. 571 // \s matches \n/\r. If reconstructCommand emits an unquoted newline between 572 // `TZ=UTC` and `echo`, \s+ would match across it and strip `TZ=UTC<NL>`, 573 // leaving `echo curl evil.com` to match Bash(echo:*). But bash treats the 574 // newline as a command separator. Defense-in-depth with needsQuoting fix. 575 const ENV_VAR_PATTERN = /^([A-Za-z_][A-Za-z0-9_]*)=([A-Za-z0-9_./:-]+)[ \t]+/ 576 577 let stripped = command 578 let previousStripped = '' 579 580 // Phase 1: Strip leading env vars and comments only. 581 // In bash, env var assignments before a command (VAR=val cmd) are genuine 582 // shell-level assignments. These are safe to strip for permission matching. 583 while (stripped !== previousStripped) { 584 previousStripped = stripped 585 stripped = stripCommentLines(stripped) 586 587 const envVarMatch = stripped.match(ENV_VAR_PATTERN) 588 if (envVarMatch) { 589 const varName = envVarMatch[1]! 590 const isAntOnlySafe = 591 process.env.USER_TYPE === 'ant' && ANT_ONLY_SAFE_ENV_VARS.has(varName) 592 if (SAFE_ENV_VARS.has(varName) || isAntOnlySafe) { 593 stripped = stripped.replace(ENV_VAR_PATTERN, '') 594 } 595 } 596 } 597 598 // Phase 2: Strip wrapper commands and comments only. Do NOT strip env vars. 599 // Wrapper commands (timeout, time, nice, nohup) use execvp to run their 600 // arguments, so VAR=val after a wrapper is treated as the COMMAND to execute, 601 // not as an env var assignment. Stripping env vars here would create a 602 // mismatch between what the parser sees and what actually executes. 603 // (HackerOne #3543050) 604 previousStripped = '' 605 while (stripped !== previousStripped) { 606 previousStripped = stripped 607 stripped = stripCommentLines(stripped) 608 609 for (const pattern of SAFE_WRAPPER_PATTERNS) { 610 stripped = stripped.replace(pattern, '') 611 } 612 } 613 614 return stripped.trim() 615} 616 617// SECURITY: allowlist for timeout flag VALUES (signals are TERM/KILL/9, 618// durations are 5/5s/10.5). Rejects $ ( ) ` | ; & and newlines that 619// previously matched via [^ \t]+ — `timeout -k$(id) 10 ls` must NOT strip. 620const TIMEOUT_FLAG_VALUE_RE = /^[A-Za-z0-9_.+-]+$/ 621 622/** 623 * Parse timeout's GNU flags (long + short, fused + space-separated) and 624 * return the argv index of the DURATION token, or -1 if flags are unparseable. 625 * Enumerates: --foreground/--preserve-status/--verbose (no value), 626 * --kill-after/--signal (value, both =fused and space-separated), -v (no 627 * value), -k/-s (value, both fused and space-separated). 628 * 629 * Extracted from stripWrappersFromArgv to keep bashToolHasPermission under 630 * Bun's feature() DCE complexity threshold — inlining this breaks 631 * feature('BASH_CLASSIFIER') evaluation in classifier tests. 632 */ 633function skipTimeoutFlags(a: readonly string[]): number { 634 let i = 1 635 while (i < a.length) { 636 const arg = a[i]! 637 const next = a[i + 1] 638 if ( 639 arg === '--foreground' || 640 arg === '--preserve-status' || 641 arg === '--verbose' 642 ) 643 i++ 644 else if (/^--(?:kill-after|signal)=[A-Za-z0-9_.+-]+$/.test(arg)) i++ 645 else if ( 646 (arg === '--kill-after' || arg === '--signal') && 647 next && 648 TIMEOUT_FLAG_VALUE_RE.test(next) 649 ) 650 i += 2 651 else if (arg === '--') { 652 i++ 653 break 654 } // end-of-options marker 655 else if (arg.startsWith('--')) return -1 656 else if (arg === '-v') i++ 657 else if ( 658 (arg === '-k' || arg === '-s') && 659 next && 660 TIMEOUT_FLAG_VALUE_RE.test(next) 661 ) 662 i += 2 663 else if (/^-[ks][A-Za-z0-9_.+-]+$/.test(arg)) i++ 664 else if (arg.startsWith('-')) return -1 665 else break 666 } 667 return i 668} 669 670/** 671 * Argv-level counterpart to stripSafeWrappers. Strips the same wrapper 672 * commands (timeout, time, nice, nohup) from AST-derived argv. Env vars 673 * are already separated into SimpleCommand.envVars so no env-var stripping. 674 * 675 * KEEP IN SYNC with SAFE_WRAPPER_PATTERNS above — if you add a wrapper 676 * there, add it here too. 677 */ 678export function stripWrappersFromArgv(argv: string[]): string[] { 679 // SECURITY: Consume optional `--` after wrapper options, matching what the 680 // wrapper does. Otherwise `['nohup','--','rm','--','-/../foo']` yields `--` 681 // as baseCmd and skips path validation. See SAFE_WRAPPER_PATTERNS comment. 682 let a = argv 683 for (;;) { 684 if (a[0] === 'time' || a[0] === 'nohup') { 685 a = a.slice(a[1] === '--' ? 2 : 1) 686 } else if (a[0] === 'timeout') { 687 const i = skipTimeoutFlags(a) 688 if (i < 0 || !a[i] || !/^\d+(?:\.\d+)?[smhd]?$/.test(a[i]!)) return a 689 a = a.slice(i + 1) 690 } else if ( 691 a[0] === 'nice' && 692 a[1] === '-n' && 693 a[2] && 694 /^-?\d+$/.test(a[2]) 695 ) { 696 a = a.slice(a[3] === '--' ? 4 : 3) 697 } else { 698 return a 699 } 700 } 701} 702 703/** 704 * Env vars that make a *different binary* run (injection or resolution hijack). 705 * Heuristic only — export-&& form bypasses this, and excludedCommands isn't a 706 * security boundary anyway. 707 */ 708export const BINARY_HIJACK_VARS = /^(LD_|DYLD_|PATH$)/ 709 710/** 711 * Strip ALL leading env var prefixes from a command, regardless of whether the 712 * var name is in the safe-list. 713 * 714 * Used for deny/ask rule matching: when a user denies `claude` or `rm`, the 715 * command should stay blocked even if prefixed with arbitrary env vars like 716 * `FOO=bar claude`. The safe-list restriction in stripSafeWrappers is correct 717 * for allow rules (prevents `DOCKER_HOST=evil docker ps` from auto-matching 718 * `Bash(docker ps:*)`), but deny rules must be harder to circumvent. 719 * 720 * Also used for sandbox.excludedCommands matching (not a security boundary — 721 * permission prompts are), with BINARY_HIJACK_VARS as a blocklist. 722 * 723 * SECURITY: Uses a broader value pattern than stripSafeWrappers. The value 724 * pattern excludes only actual shell injection characters ($, backtick, ;, |, 725 * &, parens, redirects, quotes, backslash) and whitespace. Characters like 726 * =, +, @, ~, , are harmless in unquoted env var assignment position and must 727 * be matched to prevent trivial bypass via e.g. `FOO=a=b denied_command`. 728 * 729 * @param blocklist - optional regex tested against each var name; matching vars 730 * are NOT stripped (and stripping stops there). Omit for deny rules; pass 731 * BINARY_HIJACK_VARS for excludedCommands. 732 */ 733export function stripAllLeadingEnvVars( 734 command: string, 735 blocklist?: RegExp, 736): string { 737 // Broader value pattern for deny-rule stripping. Handles: 738 // 739 // - Standard assignment (FOO=bar), append (FOO+=bar), array (FOO[0]=bar) 740 // - Single-quoted values: '[^'\n\r]*' — bash suppresses all expansion 741 // - Double-quoted values with backslash escapes: "(?:\\.|[^"$`\\\n\r])*" 742 // In bash double quotes, only \$, \`, \", \\, and \newline are special. 743 // Other \x sequences are harmless, so we allow \. inside double quotes. 744 // We still exclude raw $ and ` (without backslash) to block expansion. 745 // - Unquoted values: excludes shell metacharacters, allows backslash escapes 746 // - Concatenated segments: FOO='x'y"z" — bash concatenates adjacent segments 747 // 748 // SECURITY: Trailing whitespace MUST be [ \t]+ (horizontal only), NOT \s+. 749 // 750 // The outer * matches one atomic unit per iteration: a complete quoted 751 // string, a backslash-escape pair, or a single unquoted safe character. 752 // The inner double-quote alternation (?:...|...)* is bounded by the 753 // closing ", so it cannot interact with the outer * for backtracking. 754 // 755 // Note: $ is excluded from unquoted/double-quoted value classes to block 756 // dangerous forms like $(cmd), ${var}, and $((expr)). This means 757 // FOO=$VAR is not stripped — adding $VAR matching creates ReDoS risk 758 // (CodeQL #671) and $VAR bypasses are low-priority. 759 const ENV_VAR_PATTERN = 760 /^([A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?)\+?=(?:'[^'\n\r]*'|"(?:\\.|[^"$`\\\n\r])*"|\\.|[^ \t\n\r$`;|&()<>\\\\'"])*[ \t]+/ 761 762 let stripped = command 763 let previousStripped = '' 764 765 while (stripped !== previousStripped) { 766 previousStripped = stripped 767 stripped = stripCommentLines(stripped) 768 769 const m = stripped.match(ENV_VAR_PATTERN) 770 if (!m) continue 771 if (blocklist?.test(m[1]!)) break 772 stripped = stripped.slice(m[0].length) 773 } 774 775 return stripped.trim() 776} 777 778function filterRulesByContentsMatchingInput( 779 input: z.infer<typeof BashTool.inputSchema>, 780 rules: Map<string, PermissionRule>, 781 matchMode: 'exact' | 'prefix', 782 { 783 stripAllEnvVars = false, 784 skipCompoundCheck = false, 785 }: { stripAllEnvVars?: boolean; skipCompoundCheck?: boolean } = {}, 786): PermissionRule[] { 787 const command = input.command.trim() 788 789 // Strip output redirections for permission matching 790 // This allows rules like Bash(python:*) to match "python script.py > output.txt" 791 // Security validation of redirection targets happens separately in checkPathConstraints 792 const commandWithoutRedirections = 793 extractOutputRedirections(command).commandWithoutRedirections 794 795 // For exact matching, try both the original command (to preserve quotes) 796 // and the command without redirections (to allow rules without redirections to match) 797 // For prefix matching, only use the command without redirections 798 const commandsForMatching = 799 matchMode === 'exact' 800 ? [command, commandWithoutRedirections] 801 : [commandWithoutRedirections] 802 803 // Strip safe wrapper commands (timeout, time, nice, nohup) and env vars for matching 804 // This allows rules like Bash(npm install:*) to match "timeout 10 npm install foo" 805 // or "GOOS=linux go build" 806 const commandsToTry = commandsForMatching.flatMap(cmd => { 807 const strippedCommand = stripSafeWrappers(cmd) 808 return strippedCommand !== cmd ? [cmd, strippedCommand] : [cmd] 809 }) 810 811 // SECURITY: For deny/ask rules, also try matching after stripping ALL leading 812 // env var prefixes. This prevents bypass via `FOO=bar denied_command` where 813 // FOO is not in the safe-list. The safe-list restriction in stripSafeWrappers 814 // is intentional for allow rules (see HackerOne #3543050), but deny rules 815 // must be harder to circumvent — a denied command should stay denied 816 // regardless of env var prefixes. 817 // 818 // We iteratively apply both stripping operations to all candidates until no 819 // new candidates are produced (fixed-point). This handles interleaved patterns 820 // like `nohup FOO=bar timeout 5 claude` where: 821 // 1. stripSafeWrappers strips `nohup` → `FOO=bar timeout 5 claude` 822 // 2. stripAllLeadingEnvVars strips `FOO=bar` → `timeout 5 claude` 823 // 3. stripSafeWrappers strips `timeout 5` → `claude` (deny match) 824 // 825 // Without iteration, single-pass compositions miss multi-layer interleaving. 826 if (stripAllEnvVars) { 827 const seen = new Set(commandsToTry) 828 let startIdx = 0 829 830 // Iterate until no new candidates are produced (fixed-point) 831 while (startIdx < commandsToTry.length) { 832 const endIdx = commandsToTry.length 833 for (let i = startIdx; i < endIdx; i++) { 834 const cmd = commandsToTry[i] 835 if (!cmd) { 836 continue 837 } 838 // Try stripping env vars 839 const envStripped = stripAllLeadingEnvVars(cmd) 840 if (!seen.has(envStripped)) { 841 commandsToTry.push(envStripped) 842 seen.add(envStripped) 843 } 844 // Try stripping safe wrappers 845 const wrapperStripped = stripSafeWrappers(cmd) 846 if (!seen.has(wrapperStripped)) { 847 commandsToTry.push(wrapperStripped) 848 seen.add(wrapperStripped) 849 } 850 } 851 startIdx = endIdx 852 } 853 } 854 855 // Precompute compound-command status for each candidate to avoid re-parsing 856 // inside the rule filter loop (which would scale splitCommand calls with 857 // rules.length × commandsToTry.length). The compound check only applies to 858 // prefix/wildcard matching in 'prefix' mode, and only for allow rules. 859 // SECURITY: deny/ask rules must match compound commands so they can't be 860 // bypassed by wrapping a denied command in a compound expression. 861 const isCompoundCommand = new Map<string, boolean>() 862 if (matchMode === 'prefix' && !skipCompoundCheck) { 863 for (const cmd of commandsToTry) { 864 if (!isCompoundCommand.has(cmd)) { 865 isCompoundCommand.set(cmd, splitCommand(cmd).length > 1) 866 } 867 } 868 } 869 870 return Array.from(rules.entries()) 871 .filter(([ruleContent]) => { 872 const bashRule = bashPermissionRule(ruleContent) 873 874 return commandsToTry.some(cmdToMatch => { 875 switch (bashRule.type) { 876 case 'exact': 877 return bashRule.command === cmdToMatch 878 case 'prefix': 879 switch (matchMode) { 880 // In 'exact' mode, only return true if the command exactly matches the prefix rule 881 case 'exact': 882 return bashRule.prefix === cmdToMatch 883 case 'prefix': { 884 // SECURITY: Don't allow prefix rules to match compound commands. 885 // e.g., Bash(cd:*) must NOT match "cd /path && python3 evil.py". 886 // In the normal flow commands are split before reaching here, but 887 // shell escaping can defeat the first splitCommand pass — e.g., 888 // cd src\&\& python3 hello.py → splitCommand → ["cd src&& python3 hello.py"] 889 // which then looks like a single command that starts with "cd ". 890 // Re-splitting the candidate here catches those cases. 891 if (isCompoundCommand.get(cmdToMatch)) { 892 return false 893 } 894 // Ensure word boundary: prefix must be followed by space or end of string 895 // This prevents "ls:*" from matching "lsof" or "lsattr" 896 if (cmdToMatch === bashRule.prefix) { 897 return true 898 } 899 if (cmdToMatch.startsWith(bashRule.prefix + ' ')) { 900 return true 901 } 902 // Also match "xargs <prefix>" for bare xargs with no flags. 903 // This allows Bash(grep:*) to match "xargs grep pattern", 904 // and deny rules like Bash(rm:*) to block "xargs rm file". 905 // Natural word-boundary: "xargs -n1 grep" does NOT start with 906 // "xargs grep " so flagged xargs invocations are not matched. 907 const xargsPrefix = 'xargs ' + bashRule.prefix 908 if (cmdToMatch === xargsPrefix) { 909 return true 910 } 911 return cmdToMatch.startsWith(xargsPrefix + ' ') 912 } 913 } 914 break 915 case 'wildcard': 916 // SECURITY FIX: In exact match mode, wildcards must NOT match because we're 917 // checking the full unparsed command. Wildcard matching on unparsed commands 918 // allows "foo *" to match "foo arg && curl evil.com" since .* matches operators. 919 // Wildcards should only match after splitting into individual subcommands. 920 if (matchMode === 'exact') { 921 return false 922 } 923 // SECURITY: Same as for prefix rules, don't allow wildcard rules to match 924 // compound commands in prefix mode. e.g., Bash(cd *) must not match 925 // "cd /path && python3 evil.py" even though "cd *" pattern would match it. 926 if (isCompoundCommand.get(cmdToMatch)) { 927 return false 928 } 929 // In prefix mode (after splitting), wildcards can safely match subcommands 930 return matchWildcardPattern(bashRule.pattern, cmdToMatch) 931 } 932 }) 933 }) 934 .map(([, rule]) => rule) 935} 936 937function matchingRulesForInput( 938 input: z.infer<typeof BashTool.inputSchema>, 939 toolPermissionContext: ToolPermissionContext, 940 matchMode: 'exact' | 'prefix', 941 { skipCompoundCheck = false }: { skipCompoundCheck?: boolean } = {}, 942) { 943 const denyRuleByContents = getRuleByContentsForTool( 944 toolPermissionContext, 945 BashTool, 946 'deny', 947 ) 948 // SECURITY: Deny/ask rules use aggressive env var stripping so that 949 // `FOO=bar denied_command` still matches a deny rule for `denied_command`. 950 const matchingDenyRules = filterRulesByContentsMatchingInput( 951 input, 952 denyRuleByContents, 953 matchMode, 954 { stripAllEnvVars: true, skipCompoundCheck: true }, 955 ) 956 957 const askRuleByContents = getRuleByContentsForTool( 958 toolPermissionContext, 959 BashTool, 960 'ask', 961 ) 962 const matchingAskRules = filterRulesByContentsMatchingInput( 963 input, 964 askRuleByContents, 965 matchMode, 966 { stripAllEnvVars: true, skipCompoundCheck: true }, 967 ) 968 969 const allowRuleByContents = getRuleByContentsForTool( 970 toolPermissionContext, 971 BashTool, 972 'allow', 973 ) 974 const matchingAllowRules = filterRulesByContentsMatchingInput( 975 input, 976 allowRuleByContents, 977 matchMode, 978 { skipCompoundCheck }, 979 ) 980 981 return { 982 matchingDenyRules, 983 matchingAskRules, 984 matchingAllowRules, 985 } 986} 987 988/** 989 * Checks if the subcommand is an exact match for a permission rule 990 */ 991export const bashToolCheckExactMatchPermission = ( 992 input: z.infer<typeof BashTool.inputSchema>, 993 toolPermissionContext: ToolPermissionContext, 994): PermissionResult => { 995 const command = input.command.trim() 996 const { matchingDenyRules, matchingAskRules, matchingAllowRules } = 997 matchingRulesForInput(input, toolPermissionContext, 'exact') 998 999 // 1. Deny if exact command was denied 1000 if (matchingDenyRules[0] !== undefined) { 1001 return { 1002 behavior: 'deny', 1003 message: `Permission to use ${BashTool.name} with command ${command} has been denied.`, 1004 decisionReason: { 1005 type: 'rule', 1006 rule: matchingDenyRules[0], 1007 }, 1008 } 1009 } 1010 1011 // 2. Ask if exact command was in ask rules 1012 if (matchingAskRules[0] !== undefined) { 1013 return { 1014 behavior: 'ask', 1015 message: createPermissionRequestMessage(BashTool.name), 1016 decisionReason: { 1017 type: 'rule', 1018 rule: matchingAskRules[0], 1019 }, 1020 } 1021 } 1022 1023 // 3. Allow if exact command was allowed 1024 if (matchingAllowRules[0] !== undefined) { 1025 return { 1026 behavior: 'allow', 1027 updatedInput: input, 1028 decisionReason: { 1029 type: 'rule', 1030 rule: matchingAllowRules[0], 1031 }, 1032 } 1033 } 1034 1035 // 4. Otherwise, passthrough 1036 const decisionReason = { 1037 type: 'other' as const, 1038 reason: 'This command requires approval', 1039 } 1040 return { 1041 behavior: 'passthrough', 1042 message: createPermissionRequestMessage(BashTool.name, decisionReason), 1043 decisionReason, 1044 // Suggest exact match rule to user 1045 // this may be overridden by prefix suggestions in `checkCommandAndSuggestRules()` 1046 suggestions: suggestionForExactCommand(command), 1047 } 1048} 1049 1050export const bashToolCheckPermission = ( 1051 input: z.infer<typeof BashTool.inputSchema>, 1052 toolPermissionContext: ToolPermissionContext, 1053 compoundCommandHasCd?: boolean, 1054 astCommand?: SimpleCommand, 1055): PermissionResult => { 1056 const command = input.command.trim() 1057 1058 // 1. Check exact match first 1059 const exactMatchResult = bashToolCheckExactMatchPermission( 1060 input, 1061 toolPermissionContext, 1062 ) 1063 1064 // 1a. Deny/ask if exact command has a rule 1065 if ( 1066 exactMatchResult.behavior === 'deny' || 1067 exactMatchResult.behavior === 'ask' 1068 ) { 1069 return exactMatchResult 1070 } 1071 1072 // 2. Find all matching rules (prefix or exact) 1073 // SECURITY FIX: Check Bash deny/ask rules BEFORE path constraints to prevent bypass 1074 // via absolute paths outside the project directory (HackerOne report) 1075 // When AST-parsed, the subcommand is already atomic — skip the legacy 1076 // splitCommand re-check that misparses mid-word # as compound. 1077 const { matchingDenyRules, matchingAskRules, matchingAllowRules } = 1078 matchingRulesForInput(input, toolPermissionContext, 'prefix', { 1079 skipCompoundCheck: astCommand !== undefined, 1080 }) 1081 1082 // 2a. Deny if command has a deny rule 1083 if (matchingDenyRules[0] !== undefined) { 1084 return { 1085 behavior: 'deny', 1086 message: `Permission to use ${BashTool.name} with command ${command} has been denied.`, 1087 decisionReason: { 1088 type: 'rule', 1089 rule: matchingDenyRules[0], 1090 }, 1091 } 1092 } 1093 1094 // 2b. Ask if command has an ask rule 1095 if (matchingAskRules[0] !== undefined) { 1096 return { 1097 behavior: 'ask', 1098 message: createPermissionRequestMessage(BashTool.name), 1099 decisionReason: { 1100 type: 'rule', 1101 rule: matchingAskRules[0], 1102 }, 1103 } 1104 } 1105 1106 // 3. Check path constraints 1107 // This check comes after deny/ask rules so explicit rules take precedence. 1108 // SECURITY: When AST-derived argv is available for this subcommand, pass 1109 // it through so checkPathConstraints uses it directly instead of re-parsing 1110 // with shell-quote (which has a single-quote backslash bug that causes 1111 // parseCommandArguments to return [] and silently skip path validation). 1112 const pathResult = checkPathConstraints( 1113 input, 1114 getCwd(), 1115 toolPermissionContext, 1116 compoundCommandHasCd, 1117 astCommand?.redirects, 1118 astCommand ? [astCommand] : undefined, 1119 ) 1120 if (pathResult.behavior !== 'passthrough') { 1121 return pathResult 1122 } 1123 1124 // 4. Allow if command had an exact match allow 1125 if (exactMatchResult.behavior === 'allow') { 1126 return exactMatchResult 1127 } 1128 1129 // 5. Allow if command has an allow rule 1130 if (matchingAllowRules[0] !== undefined) { 1131 return { 1132 behavior: 'allow', 1133 updatedInput: input, 1134 decisionReason: { 1135 type: 'rule', 1136 rule: matchingAllowRules[0], 1137 }, 1138 } 1139 } 1140 1141 // 5b. Check sed constraints (blocks dangerous sed operations before mode auto-allow) 1142 const sedConstraintResult = checkSedConstraints(input, toolPermissionContext) 1143 if (sedConstraintResult.behavior !== 'passthrough') { 1144 return sedConstraintResult 1145 } 1146 1147 // 6. Check for mode-specific permission handling 1148 const modeResult = checkPermissionMode(input, toolPermissionContext) 1149 if (modeResult.behavior !== 'passthrough') { 1150 return modeResult 1151 } 1152 1153 // 7. Check read-only rules 1154 if (BashTool.isReadOnly(input)) { 1155 return { 1156 behavior: 'allow', 1157 updatedInput: input, 1158 decisionReason: { 1159 type: 'other', 1160 reason: 'Read-only command is allowed', 1161 }, 1162 } 1163 } 1164 1165 // 8. Passthrough since no rules match, will trigger permission prompt 1166 const decisionReason = { 1167 type: 'other' as const, 1168 reason: 'This command requires approval', 1169 } 1170 return { 1171 behavior: 'passthrough', 1172 message: createPermissionRequestMessage(BashTool.name, decisionReason), 1173 decisionReason, 1174 // Suggest exact match rule to user 1175 // this may be overridden by prefix suggestions in `checkCommandAndSuggestRules()` 1176 suggestions: suggestionForExactCommand(command), 1177 } 1178} 1179 1180/** 1181 * Processes an individual subcommand and applies prefix checks & suggestions 1182 */ 1183export async function checkCommandAndSuggestRules( 1184 input: z.infer<typeof BashTool.inputSchema>, 1185 toolPermissionContext: ToolPermissionContext, 1186 commandPrefixResult: CommandPrefixResult | null | undefined, 1187 compoundCommandHasCd?: boolean, 1188 astParseSucceeded?: boolean, 1189): Promise<PermissionResult> { 1190 // 1. Check exact match first 1191 const exactMatchResult = bashToolCheckExactMatchPermission( 1192 input, 1193 toolPermissionContext, 1194 ) 1195 if (exactMatchResult.behavior !== 'passthrough') { 1196 return exactMatchResult 1197 } 1198 1199 // 2. Check the command prefix 1200 const permissionResult = bashToolCheckPermission( 1201 input, 1202 toolPermissionContext, 1203 compoundCommandHasCd, 1204 ) 1205 // 2a. Deny/ask if command was explictly denied/asked 1206 if ( 1207 permissionResult.behavior === 'deny' || 1208 permissionResult.behavior === 'ask' 1209 ) { 1210 return permissionResult 1211 } 1212 1213 // 3. Ask for permission if command injection is detected. Skip when the 1214 // AST parse already succeeded — tree-sitter has verified there are no 1215 // hidden substitutions or structural tricks, so the legacy regex-based 1216 // validators (backslash-escaped operators, etc.) would only add FPs. 1217 if ( 1218 !astParseSucceeded && 1219 !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_COMMAND_INJECTION_CHECK) 1220 ) { 1221 const safetyResult = await bashCommandIsSafeAsync(input.command) 1222 1223 if (safetyResult.behavior !== 'passthrough') { 1224 const decisionReason: PermissionDecisionReason = { 1225 type: 'other' as const, 1226 reason: 1227 safetyResult.behavior === 'ask' && safetyResult.message 1228 ? safetyResult.message 1229 : 'This command contains patterns that could pose security risks and requires approval', 1230 } 1231 1232 return { 1233 behavior: 'ask', 1234 message: createPermissionRequestMessage(BashTool.name, decisionReason), 1235 decisionReason, 1236 suggestions: [], // Don't suggest saving a potentially dangerous command 1237 } 1238 } 1239 } 1240 1241 // 4. Allow if command was allowed 1242 if (permissionResult.behavior === 'allow') { 1243 return permissionResult 1244 } 1245 1246 // 5. Suggest prefix if available, otherwise exact command 1247 const suggestedUpdates = commandPrefixResult?.commandPrefix 1248 ? suggestionForPrefix(commandPrefixResult.commandPrefix) 1249 : suggestionForExactCommand(input.command) 1250 1251 return { 1252 ...permissionResult, 1253 suggestions: suggestedUpdates, 1254 } 1255} 1256 1257/** 1258 * Checks if a command should be auto-allowed when sandboxed. 1259 * Returns early if there are explicit deny/ask rules that should be respected. 1260 * 1261 * NOTE: This function should only be called when sandboxing and auto-allow are enabled. 1262 * 1263 * @param input - The bash tool input 1264 * @param toolPermissionContext - The permission context 1265 * @returns PermissionResult with: 1266 * - deny/ask if explicit rule exists (exact or prefix) 1267 * - allow if no explicit rules (sandbox auto-allow applies) 1268 * - passthrough should not occur since we're in auto-allow mode 1269 */ 1270function checkSandboxAutoAllow( 1271 input: z.infer<typeof BashTool.inputSchema>, 1272 toolPermissionContext: ToolPermissionContext, 1273): PermissionResult { 1274 const command = input.command.trim() 1275 1276 // Check for explicit deny/ask rules on the full command (exact + prefix) 1277 const { matchingDenyRules, matchingAskRules } = matchingRulesForInput( 1278 input, 1279 toolPermissionContext, 1280 'prefix', 1281 ) 1282 1283 // Return immediately if there's an explicit deny rule on the full command 1284 if (matchingDenyRules[0] !== undefined) { 1285 return { 1286 behavior: 'deny', 1287 message: `Permission to use ${BashTool.name} with command ${command} has been denied.`, 1288 decisionReason: { 1289 type: 'rule', 1290 rule: matchingDenyRules[0], 1291 }, 1292 } 1293 } 1294 1295 // SECURITY: For compound commands, check each subcommand against deny/ask 1296 // rules. Prefix rules like Bash(rm:*) won't match the full compound command 1297 // (e.g., "echo hello && rm -rf /" doesn't start with "rm"), so we must 1298 // check each subcommand individually. 1299 // IMPORTANT: Subcommand deny checks must run BEFORE full-command ask returns. 1300 // Otherwise a wildcard ask rule matching the full command (e.g., Bash(*echo*)) 1301 // would return 'ask' before a prefix deny rule on a subcommand (e.g., Bash(rm:*)) 1302 // gets checked, downgrading a deny to an ask. 1303 const subcommands = splitCommand(command) 1304 if (subcommands.length > 1) { 1305 let firstAskRule: PermissionRule | undefined 1306 for (const sub of subcommands) { 1307 const subResult = matchingRulesForInput( 1308 { command: sub }, 1309 toolPermissionContext, 1310 'prefix', 1311 ) 1312 // Deny takes priority — return immediately 1313 if (subResult.matchingDenyRules[0] !== undefined) { 1314 return { 1315 behavior: 'deny', 1316 message: `Permission to use ${BashTool.name} with command ${command} has been denied.`, 1317 decisionReason: { 1318 type: 'rule', 1319 rule: subResult.matchingDenyRules[0], 1320 }, 1321 } 1322 } 1323 // Stash first ask match; don't return yet (deny across all subs takes priority) 1324 firstAskRule ??= subResult.matchingAskRules[0] 1325 } 1326 if (firstAskRule) { 1327 return { 1328 behavior: 'ask', 1329 message: createPermissionRequestMessage(BashTool.name), 1330 decisionReason: { 1331 type: 'rule', 1332 rule: firstAskRule, 1333 }, 1334 } 1335 } 1336 } 1337 1338 // Full-command ask check (after all deny sources have been exhausted) 1339 if (matchingAskRules[0] !== undefined) { 1340 return { 1341 behavior: 'ask', 1342 message: createPermissionRequestMessage(BashTool.name), 1343 decisionReason: { 1344 type: 'rule', 1345 rule: matchingAskRules[0], 1346 }, 1347 } 1348 } 1349 // No explicit rules, so auto-allow with sandbox 1350 1351 return { 1352 behavior: 'allow', 1353 updatedInput: input, 1354 decisionReason: { 1355 type: 'other', 1356 reason: 'Auto-allowed with sandbox (autoAllowBashIfSandboxed enabled)', 1357 }, 1358 } 1359} 1360 1361/** 1362 * Filter out `cd ${cwd}` prefix subcommands, keeping astCommands aligned. 1363 * Extracted to keep bashToolHasPermission under Bun's feature() DCE 1364 * complexity threshold — inlining this breaks pendingClassifierCheck 1365 * attachment in ~10 classifier tests. 1366 */ 1367function filterCdCwdSubcommands( 1368 rawSubcommands: string[], 1369 astCommands: SimpleCommand[] | undefined, 1370 cwd: string, 1371 cwdMingw: string, 1372): { subcommands: string[]; astCommandsByIdx: (SimpleCommand | undefined)[] } { 1373 const subcommands: string[] = [] 1374 const astCommandsByIdx: (SimpleCommand | undefined)[] = [] 1375 for (let i = 0; i < rawSubcommands.length; i++) { 1376 const cmd = rawSubcommands[i]! 1377 if (cmd === `cd ${cwd}` || cmd === `cd ${cwdMingw}`) continue 1378 subcommands.push(cmd) 1379 astCommandsByIdx.push(astCommands?.[i]) 1380 } 1381 return { subcommands, astCommandsByIdx } 1382} 1383 1384/** 1385 * Early-exit deny enforcement for the AST too-complex and checkSemantics 1386 * paths. Returns the exact-match result if non-passthrough (deny/ask/allow), 1387 * then checks prefix/wildcard deny rules. Returns null if neither matched, 1388 * meaning the caller should fall through to ask. Extracted to keep 1389 * bashToolHasPermission under Bun's feature() DCE complexity threshold. 1390 */ 1391function checkEarlyExitDeny( 1392 input: z.infer<typeof BashTool.inputSchema>, 1393 toolPermissionContext: ToolPermissionContext, 1394): PermissionResult | null { 1395 const exactMatchResult = bashToolCheckExactMatchPermission( 1396 input, 1397 toolPermissionContext, 1398 ) 1399 if (exactMatchResult.behavior !== 'passthrough') { 1400 return exactMatchResult 1401 } 1402 const denyMatch = matchingRulesForInput( 1403 input, 1404 toolPermissionContext, 1405 'prefix', 1406 ).matchingDenyRules[0] 1407 if (denyMatch !== undefined) { 1408 return { 1409 behavior: 'deny', 1410 message: `Permission to use ${BashTool.name} with command ${input.command} has been denied.`, 1411 decisionReason: { type: 'rule', rule: denyMatch }, 1412 } 1413 } 1414 return null 1415} 1416 1417/** 1418 * checkSemantics-path deny enforcement. Calls checkEarlyExitDeny (exact-match 1419 * + full-command prefix deny), then checks each individual SimpleCommand .text 1420 * span against prefix deny rules. The per-subcommand check is needed because 1421 * filterRulesByContentsMatchingInput has a compound-command guard 1422 * (splitCommand().length > 1 → prefix rules return false) that defeats 1423 * `Bash(eval:*)` matching against a full pipeline like `echo foo | eval rm`. 1424 * Each SimpleCommand span is a single command, so the guard doesn't fire. 1425 * 1426 * Separate helper (not folded into checkEarlyExitDeny or inlined at the call 1427 * site) because bashToolHasPermission is tight against Bun's feature() DCE 1428 * complexity threshold — adding even ~5 lines there breaks 1429 * feature('BASH_CLASSIFIER') evaluation and drops pendingClassifierCheck. 1430 */ 1431function checkSemanticsDeny( 1432 input: z.infer<typeof BashTool.inputSchema>, 1433 toolPermissionContext: ToolPermissionContext, 1434 commands: readonly { text: string }[], 1435): PermissionResult | null { 1436 const fullCmd = checkEarlyExitDeny(input, toolPermissionContext) 1437 if (fullCmd !== null) return fullCmd 1438 for (const cmd of commands) { 1439 const subDeny = matchingRulesForInput( 1440 { ...input, command: cmd.text }, 1441 toolPermissionContext, 1442 'prefix', 1443 ).matchingDenyRules[0] 1444 if (subDeny !== undefined) { 1445 return { 1446 behavior: 'deny', 1447 message: `Permission to use ${BashTool.name} with command ${input.command} has been denied.`, 1448 decisionReason: { type: 'rule', rule: subDeny }, 1449 } 1450 } 1451 } 1452 return null 1453} 1454 1455/** 1456 * Builds the pending classifier check metadata if classifier is enabled and has allow descriptions. 1457 * Returns undefined if classifier is disabled, in auto mode, or no allow descriptions exist. 1458 */ 1459function buildPendingClassifierCheck( 1460 command: string, 1461 toolPermissionContext: ToolPermissionContext, 1462): { command: string; cwd: string; descriptions: string[] } | undefined { 1463 if (!isClassifierPermissionsEnabled()) { 1464 return undefined 1465 } 1466 // Skip in auto mode - auto mode classifier handles all permission decisions 1467 if (feature('TRANSCRIPT_CLASSIFIER') && toolPermissionContext.mode === 'auto') 1468 return undefined 1469 if (toolPermissionContext.mode === 'bypassPermissions') return undefined 1470 1471 const allowDescriptions = getBashPromptAllowDescriptions( 1472 toolPermissionContext, 1473 ) 1474 if (allowDescriptions.length === 0) return undefined 1475 1476 return { 1477 command, 1478 cwd: getCwd(), 1479 descriptions: allowDescriptions, 1480 } 1481} 1482 1483const speculativeChecks = new Map<string, Promise<ClassifierResult>>() 1484 1485/** 1486 * Start a speculative bash allow classifier check early, so it runs in 1487 * parallel with pre-tool hooks, deny/ask classifiers, and permission dialog setup. 1488 * The result can be consumed later by executeAsyncClassifierCheck via 1489 * consumeSpeculativeClassifierCheck. 1490 */ 1491export function peekSpeculativeClassifierCheck( 1492 command: string, 1493): Promise<ClassifierResult> | undefined { 1494 return speculativeChecks.get(command) 1495} 1496 1497export function startSpeculativeClassifierCheck( 1498 command: string, 1499 toolPermissionContext: ToolPermissionContext, 1500 signal: AbortSignal, 1501 isNonInteractiveSession: boolean, 1502): boolean { 1503 // Same guards as buildPendingClassifierCheck 1504 if (!isClassifierPermissionsEnabled()) return false 1505 if (feature('TRANSCRIPT_CLASSIFIER') && toolPermissionContext.mode === 'auto') 1506 return false 1507 if (toolPermissionContext.mode === 'bypassPermissions') return false 1508 const allowDescriptions = getBashPromptAllowDescriptions( 1509 toolPermissionContext, 1510 ) 1511 if (allowDescriptions.length === 0) return false 1512 1513 const cwd = getCwd() 1514 const promise = classifyBashCommand( 1515 command, 1516 cwd, 1517 allowDescriptions, 1518 'allow', 1519 signal, 1520 isNonInteractiveSession, 1521 ) 1522 // Prevent unhandled rejection if the signal aborts before this promise is consumed. 1523 // The original promise (which may reject) is still stored in the Map for consumers to await. 1524 promise.catch(() => {}) 1525 speculativeChecks.set(command, promise) 1526 return true 1527} 1528 1529/** 1530 * Consume a speculative classifier check result for the given command. 1531 * Returns the promise if one exists (and removes it from the map), or undefined. 1532 */ 1533export function consumeSpeculativeClassifierCheck( 1534 command: string, 1535): Promise<ClassifierResult> | undefined { 1536 const promise = speculativeChecks.get(command) 1537 if (promise) { 1538 speculativeChecks.delete(command) 1539 } 1540 return promise 1541} 1542 1543export function clearSpeculativeChecks(): void { 1544 speculativeChecks.clear() 1545} 1546 1547/** 1548 * Await a pending classifier check and return a PermissionDecisionReason if 1549 * high-confidence allow, or undefined otherwise. 1550 * 1551 * Used by swarm agents (both tmux and in-process) to gate permission 1552 * forwarding: run the classifier first, and only escalate to the leader 1553 * if the classifier doesn't auto-approve. 1554 */ 1555export async function awaitClassifierAutoApproval( 1556 pendingCheck: PendingClassifierCheck, 1557 signal: AbortSignal, 1558 isNonInteractiveSession: boolean, 1559): Promise<PermissionDecisionReason | undefined> { 1560 const { command, cwd, descriptions } = pendingCheck 1561 const speculativeResult = consumeSpeculativeClassifierCheck(command) 1562 const classifierResult = speculativeResult 1563 ? await speculativeResult 1564 : await classifyBashCommand( 1565 command, 1566 cwd, 1567 descriptions, 1568 'allow', 1569 signal, 1570 isNonInteractiveSession, 1571 ) 1572 1573 logClassifierResultForAnts(command, 'allow', descriptions, classifierResult) 1574 1575 if ( 1576 feature('BASH_CLASSIFIER') && 1577 classifierResult.matches && 1578 classifierResult.confidence === 'high' 1579 ) { 1580 return { 1581 type: 'classifier', 1582 classifier: 'bash_allow', 1583 reason: `Allowed by prompt rule: "${classifierResult.matchedDescription}"`, 1584 } 1585 } 1586 return undefined 1587} 1588 1589type AsyncClassifierCheckCallbacks = { 1590 shouldContinue: () => boolean 1591 onAllow: (decisionReason: PermissionDecisionReason) => void 1592 onComplete?: () => void 1593} 1594 1595/** 1596 * Execute the bash allow classifier check asynchronously. 1597 * This runs in the background while the permission prompt is shown. 1598 * If the classifier allows with high confidence and the user hasn't interacted, auto-approves. 1599 * 1600 * @param pendingCheck - Classifier check metadata from bashToolHasPermission 1601 * @param signal - Abort signal 1602 * @param isNonInteractiveSession - Whether this is a non-interactive session 1603 * @param callbacks - Callbacks to check if we should continue and handle approval 1604 */ 1605export async function executeAsyncClassifierCheck( 1606 pendingCheck: { command: string; cwd: string; descriptions: string[] }, 1607 signal: AbortSignal, 1608 isNonInteractiveSession: boolean, 1609 callbacks: AsyncClassifierCheckCallbacks, 1610): Promise<void> { 1611 const { command, cwd, descriptions } = pendingCheck 1612 const speculativeResult = consumeSpeculativeClassifierCheck(command) 1613 1614 let classifierResult: ClassifierResult 1615 try { 1616 classifierResult = speculativeResult 1617 ? await speculativeResult 1618 : await classifyBashCommand( 1619 command, 1620 cwd, 1621 descriptions, 1622 'allow', 1623 signal, 1624 isNonInteractiveSession, 1625 ) 1626 } catch (error: unknown) { 1627 // When the coordinator session is cancelled, the abort signal fires and the 1628 // classifier API call rejects with APIUserAbortError. This is expected and 1629 // should not surface as an unhandled promise rejection. 1630 if (error instanceof APIUserAbortError || error instanceof AbortError) { 1631 callbacks.onComplete?.() 1632 return 1633 } 1634 callbacks.onComplete?.() 1635 throw error 1636 } 1637 1638 logClassifierResultForAnts(command, 'allow', descriptions, classifierResult) 1639 1640 // Don't auto-approve if user already made a decision or has interacted 1641 // with the permission dialog (e.g., arrow keys, tab, typing) 1642 if (!callbacks.shouldContinue()) return 1643 1644 if ( 1645 feature('BASH_CLASSIFIER') && 1646 classifierResult.matches && 1647 classifierResult.confidence === 'high' 1648 ) { 1649 callbacks.onAllow({ 1650 type: 'classifier', 1651 classifier: 'bash_allow', 1652 reason: `Allowed by prompt rule: "${classifierResult.matchedDescription}"`, 1653 }) 1654 } else { 1655 // No match — notify so the checking indicator is cleared 1656 callbacks.onComplete?.() 1657 } 1658} 1659 1660/** 1661 * The main implementation to check if we need to ask for user permission to call BashTool with a given input 1662 */ 1663export async function bashToolHasPermission( 1664 input: z.infer<typeof BashTool.inputSchema>, 1665 context: ToolUseContext, 1666 getCommandSubcommandPrefixFn = getCommandSubcommandPrefix, 1667): Promise<PermissionResult> { 1668 let appState = context.getAppState() 1669 1670 // 0. AST-based security parse. This replaces both tryParseShellCommand 1671 // (the shell-quote pre-check) and the bashCommandIsSafe misparsing gate. 1672 // tree-sitter produces either a clean SimpleCommand[] (quotes resolved, 1673 // no hidden substitutions) or 'too-complex' — which is exactly the signal 1674 // we need to decide whether splitCommand's output can be trusted. 1675 // 1676 // When tree-sitter WASM is unavailable OR the injection check is disabled 1677 // via env var, we fall back to the old path (legacy gate at ~1370 runs). 1678 const injectionCheckDisabled = isEnvTruthy( 1679 process.env.CLAUDE_CODE_DISABLE_COMMAND_INJECTION_CHECK, 1680 ) 1681 // GrowthBook killswitch for shadow mode — when off, skip the native parse 1682 // entirely. Computed once; feature() must stay inline in the ternary below. 1683 const shadowEnabled = feature('TREE_SITTER_BASH_SHADOW') 1684 ? getFeatureValue_CACHED_MAY_BE_STALE('tengu_birch_trellis', true) 1685 : false 1686 // Parse once here; the resulting AST feeds both parseForSecurityFromAst 1687 // and bashToolCheckCommandOperatorPermissions. 1688 let astRoot = injectionCheckDisabled 1689 ? null 1690 : feature('TREE_SITTER_BASH_SHADOW') && !shadowEnabled 1691 ? null 1692 : await parseCommandRaw(input.command) 1693 let astResult: ParseForSecurityResult = astRoot 1694 ? parseForSecurityFromAst(input.command, astRoot) 1695 : { kind: 'parse-unavailable' } 1696 let astSubcommands: string[] | null = null 1697 let astRedirects: Redirect[] | undefined 1698 let astCommands: SimpleCommand[] | undefined 1699 let shadowLegacySubs: string[] | undefined 1700 1701 // Shadow-test tree-sitter: record its verdict, then force parse-unavailable 1702 // so the legacy path stays authoritative. parseCommand stays gated on 1703 // TREE_SITTER_BASH (not SHADOW) so legacy internals remain pure regex. 1704 // One event per bash call captures both divergence AND unavailability 1705 // reasons; module-load failures are separately covered by the 1706 // session-scoped tengu_tree_sitter_load event. 1707 if (feature('TREE_SITTER_BASH_SHADOW')) { 1708 const available = astResult.kind !== 'parse-unavailable' 1709 let tooComplex = false 1710 let semanticFail = false 1711 let subsDiffer = false 1712 if (available) { 1713 tooComplex = astResult.kind === 'too-complex' 1714 semanticFail = 1715 astResult.kind === 'simple' && !checkSemantics(astResult.commands).ok 1716 const tsSubs = 1717 astResult.kind === 'simple' 1718 ? astResult.commands.map(c => c.text) 1719 : undefined 1720 const legacySubs = splitCommand(input.command) 1721 shadowLegacySubs = legacySubs 1722 subsDiffer = 1723 tsSubs !== undefined && 1724 (tsSubs.length !== legacySubs.length || 1725 tsSubs.some((s, i) => s !== legacySubs[i])) 1726 } 1727 logEvent('tengu_tree_sitter_shadow', { 1728 available, 1729 astTooComplex: tooComplex, 1730 astSemanticFail: semanticFail, 1731 subsDiffer, 1732 injectionCheckDisabled, 1733 killswitchOff: !shadowEnabled, 1734 cmdOverLength: input.command.length > 10000, 1735 }) 1736 // Always force legacy — shadow mode is observational only. 1737 astResult = { kind: 'parse-unavailable' } 1738 astRoot = null 1739 } 1740 1741 if (astResult.kind === 'too-complex') { 1742 // Parse succeeded but found structure we can't statically analyze 1743 // (command substitution, expansion, control flow, parser differential). 1744 // Respect exact-match deny/ask/allow, then prefix/wildcard deny. Only 1745 // fall through to ask if no deny matched — don't downgrade deny to ask. 1746 const earlyExit = checkEarlyExitDeny(input, appState.toolPermissionContext) 1747 if (earlyExit !== null) return earlyExit 1748 const decisionReason: PermissionDecisionReason = { 1749 type: 'other' as const, 1750 reason: astResult.reason, 1751 } 1752 logEvent('tengu_bash_ast_too_complex', { 1753 nodeTypeId: nodeTypeId(astResult.nodeType), 1754 }) 1755 return { 1756 behavior: 'ask', 1757 decisionReason, 1758 message: createPermissionRequestMessage(BashTool.name, decisionReason), 1759 suggestions: [], 1760 ...(feature('BASH_CLASSIFIER') 1761 ? { 1762 pendingClassifierCheck: buildPendingClassifierCheck( 1763 input.command, 1764 appState.toolPermissionContext, 1765 ), 1766 } 1767 : {}), 1768 } 1769 } 1770 1771 if (astResult.kind === 'simple') { 1772 // Clean parse: check semantic-level concerns (zsh builtins, eval, etc.) 1773 // that tokenize fine but are dangerous by name. 1774 const sem = checkSemantics(astResult.commands) 1775 if (!sem.ok) { 1776 // Same deny-rule enforcement as the too-complex path: a user with 1777 // `Bash(eval:*)` deny expects `eval "rm"` blocked, not downgraded. 1778 const earlyExit = checkSemanticsDeny( 1779 input, 1780 appState.toolPermissionContext, 1781 astResult.commands, 1782 ) 1783 if (earlyExit !== null) return earlyExit 1784 const decisionReason: PermissionDecisionReason = { 1785 type: 'other' as const, 1786 reason: sem.reason, 1787 } 1788 return { 1789 behavior: 'ask', 1790 decisionReason, 1791 message: createPermissionRequestMessage(BashTool.name, decisionReason), 1792 suggestions: [], 1793 } 1794 } 1795 // Stash the tokenized subcommands for use below. Downstream code (rule 1796 // matching, path extraction, cd detection) still operates on strings, so 1797 // we pass the original source span for each SimpleCommand. Downstream 1798 // processing (stripSafeWrappers, parseCommandArguments) re-tokenizes 1799 // these spans — that re-tokenization has known bugs (stripCommentLines 1800 // mishandles newlines inside quotes), but checkSemantics already caught 1801 // any argv element containing a newline, so those bugs can't bite here. 1802 // Migrating downstream to operate on argv directly is a later commit. 1803 astSubcommands = astResult.commands.map(c => c.text) 1804 astRedirects = astResult.commands.flatMap(c => c.redirects) 1805 astCommands = astResult.commands 1806 } 1807 1808 // Legacy shell-quote pre-check. Only reached on 'parse-unavailable' 1809 // (tree-sitter not loaded OR TREE_SITTER_BASH feature gated off). Falls 1810 // through to the full legacy path below. 1811 if (astResult.kind === 'parse-unavailable') { 1812 logForDebugging( 1813 'bashToolHasPermission: tree-sitter unavailable, using legacy shell-quote path', 1814 ) 1815 const parseResult = tryParseShellCommand(input.command) 1816 if (!parseResult.success) { 1817 const decisionReason = { 1818 type: 'other' as const, 1819 reason: `Command contains malformed syntax that cannot be parsed: ${parseResult.error}`, 1820 } 1821 return { 1822 behavior: 'ask', 1823 decisionReason, 1824 message: createPermissionRequestMessage(BashTool.name, decisionReason), 1825 } 1826 } 1827 } 1828 1829 // Check sandbox auto-allow (which respects explicit deny/ask rules) 1830 // Only call this if sandboxing and auto-allow are both enabled 1831 if ( 1832 SandboxManager.isSandboxingEnabled() && 1833 SandboxManager.isAutoAllowBashIfSandboxedEnabled() && 1834 shouldUseSandbox(input) 1835 ) { 1836 const sandboxAutoAllowResult = checkSandboxAutoAllow( 1837 input, 1838 appState.toolPermissionContext, 1839 ) 1840 if (sandboxAutoAllowResult.behavior !== 'passthrough') { 1841 return sandboxAutoAllowResult 1842 } 1843 } 1844 1845 // Check exact match first 1846 const exactMatchResult = bashToolCheckExactMatchPermission( 1847 input, 1848 appState.toolPermissionContext, 1849 ) 1850 1851 // Exact command was denied 1852 if (exactMatchResult.behavior === 'deny') { 1853 return exactMatchResult 1854 } 1855 1856 // Check Bash prompt deny and ask rules in parallel (both use Haiku). 1857 // Deny takes precedence over ask, and both take precedence over allow rules. 1858 // Skip when in auto mode - auto mode classifier handles all permission decisions 1859 if ( 1860 isClassifierPermissionsEnabled() && 1861 !( 1862 feature('TRANSCRIPT_CLASSIFIER') && 1863 appState.toolPermissionContext.mode === 'auto' 1864 ) 1865 ) { 1866 const denyDescriptions = getBashPromptDenyDescriptions( 1867 appState.toolPermissionContext, 1868 ) 1869 const askDescriptions = getBashPromptAskDescriptions( 1870 appState.toolPermissionContext, 1871 ) 1872 const hasDeny = denyDescriptions.length > 0 1873 const hasAsk = askDescriptions.length > 0 1874 1875 if (hasDeny || hasAsk) { 1876 const [denyResult, askResult] = await Promise.all([ 1877 hasDeny 1878 ? classifyBashCommand( 1879 input.command, 1880 getCwd(), 1881 denyDescriptions, 1882 'deny', 1883 context.abortController.signal, 1884 context.options.isNonInteractiveSession, 1885 ) 1886 : null, 1887 hasAsk 1888 ? classifyBashCommand( 1889 input.command, 1890 getCwd(), 1891 askDescriptions, 1892 'ask', 1893 context.abortController.signal, 1894 context.options.isNonInteractiveSession, 1895 ) 1896 : null, 1897 ]) 1898 1899 if (context.abortController.signal.aborted) { 1900 throw new AbortError() 1901 } 1902 1903 if (denyResult) { 1904 logClassifierResultForAnts( 1905 input.command, 1906 'deny', 1907 denyDescriptions, 1908 denyResult, 1909 ) 1910 } 1911 if (askResult) { 1912 logClassifierResultForAnts( 1913 input.command, 1914 'ask', 1915 askDescriptions, 1916 askResult, 1917 ) 1918 } 1919 1920 // Deny takes precedence 1921 if (denyResult?.matches && denyResult.confidence === 'high') { 1922 return { 1923 behavior: 'deny', 1924 message: `Denied by Bash prompt rule: "${denyResult.matchedDescription}"`, 1925 decisionReason: { 1926 type: 'other', 1927 reason: `Denied by Bash prompt rule: "${denyResult.matchedDescription}"`, 1928 }, 1929 } 1930 } 1931 1932 if (askResult?.matches && askResult.confidence === 'high') { 1933 // Skip the Haiku call — the UI computes the prefix locally 1934 // and lets the user edit it. Still call the injected function 1935 // when tests override it. 1936 let suggestions: PermissionUpdate[] 1937 if (getCommandSubcommandPrefixFn === getCommandSubcommandPrefix) { 1938 suggestions = suggestionForExactCommand(input.command) 1939 } else { 1940 const commandPrefixResult = await getCommandSubcommandPrefixFn( 1941 input.command, 1942 context.abortController.signal, 1943 context.options.isNonInteractiveSession, 1944 ) 1945 if (context.abortController.signal.aborted) { 1946 throw new AbortError() 1947 } 1948 suggestions = commandPrefixResult?.commandPrefix 1949 ? suggestionForPrefix(commandPrefixResult.commandPrefix) 1950 : suggestionForExactCommand(input.command) 1951 } 1952 return { 1953 behavior: 'ask', 1954 message: createPermissionRequestMessage(BashTool.name), 1955 decisionReason: { 1956 type: 'other', 1957 reason: `Required by Bash prompt rule: "${askResult.matchedDescription}"`, 1958 }, 1959 suggestions, 1960 ...(feature('BASH_CLASSIFIER') 1961 ? { 1962 pendingClassifierCheck: buildPendingClassifierCheck( 1963 input.command, 1964 appState.toolPermissionContext, 1965 ), 1966 } 1967 : {}), 1968 } 1969 } 1970 } 1971 } 1972 1973 // Check for non-subcommand Bash operators like `>`, `|`, etc. 1974 // This must happen before dangerous path checks so that piped commands 1975 // are handled by the operator logic (which generates "multiple operations" messages) 1976 const commandOperatorResult = await checkCommandOperatorPermissions( 1977 input, 1978 (i: z.infer<typeof BashTool.inputSchema>) => 1979 bashToolHasPermission(i, context, getCommandSubcommandPrefixFn), 1980 { isNormalizedCdCommand, isNormalizedGitCommand }, 1981 astRoot, 1982 ) 1983 if (commandOperatorResult.behavior !== 'passthrough') { 1984 // SECURITY FIX: When pipe segment processing returns 'allow', we must still validate 1985 // the ORIGINAL command. The pipe segment processing strips redirections before 1986 // checking each segment, so commands like: 1987 // echo 'x' | xargs printf '%s' >> /tmp/file 1988 // would have both segments allowed (echo and xargs printf) but the >> redirection 1989 // would bypass validation. We must check: 1990 // 1. Path constraints for output redirections 1991 // 2. Command safety for dangerous patterns (backticks, etc.) in redirect targets 1992 if (commandOperatorResult.behavior === 'allow') { 1993 // Check for dangerous patterns (backticks, $(), etc.) in the original command 1994 // This catches cases like: echo x | xargs echo > `pwd`/evil.txt 1995 // where the backtick is in the redirect target (stripped from segments) 1996 // Gate on AST: when astSubcommands is non-null, tree-sitter already 1997 // validated structure (backticks/$() in redirect targets would have 1998 // returned too-complex). Matches gating at ~1481, ~1706, ~1755. 1999 // Avoids FP: `find -exec {} \; | grep x` tripping on backslash-;. 2000 // bashCommandIsSafe runs the full legacy regex battery (~20 patterns) — 2001 // only call it when we'll actually use the result. 2002 const safetyResult = 2003 astSubcommands === null 2004 ? await bashCommandIsSafeAsync(input.command) 2005 : null 2006 if ( 2007 safetyResult !== null && 2008 safetyResult.behavior !== 'passthrough' && 2009 safetyResult.behavior !== 'allow' 2010 ) { 2011 // Attach pending classifier check - may auto-approve before user responds 2012 appState = context.getAppState() 2013 return { 2014 behavior: 'ask', 2015 message: createPermissionRequestMessage(BashTool.name, { 2016 type: 'other', 2017 reason: 2018 safetyResult.message ?? 2019 'Command contains patterns that require approval', 2020 }), 2021 decisionReason: { 2022 type: 'other', 2023 reason: 2024 safetyResult.message ?? 2025 'Command contains patterns that require approval', 2026 }, 2027 ...(feature('BASH_CLASSIFIER') 2028 ? { 2029 pendingClassifierCheck: buildPendingClassifierCheck( 2030 input.command, 2031 appState.toolPermissionContext, 2032 ), 2033 } 2034 : {}), 2035 } 2036 } 2037 2038 appState = context.getAppState() 2039 // SECURITY: Compute compoundCommandHasCd from the full command, NOT 2040 // hardcode false. The pipe-handling path previously passed `false` here, 2041 // disabling the cd+redirect check at pathValidation.ts:821. Appending 2042 // `| echo done` to `cd .claude && echo x > settings.json` routed through 2043 // this path with compoundCommandHasCd=false, letting the redirect write 2044 // to .claude/settings.json without the cd+redirect block firing. 2045 const pathResult = checkPathConstraints( 2046 input, 2047 getCwd(), 2048 appState.toolPermissionContext, 2049 commandHasAnyCd(input.command), 2050 astRedirects, 2051 astCommands, 2052 ) 2053 if (pathResult.behavior !== 'passthrough') { 2054 return pathResult 2055 } 2056 } 2057 2058 // When pipe segments return 'ask' (individual segments not allowed by rules), 2059 // attach pending classifier check - may auto-approve before user responds. 2060 if (commandOperatorResult.behavior === 'ask') { 2061 appState = context.getAppState() 2062 return { 2063 ...commandOperatorResult, 2064 ...(feature('BASH_CLASSIFIER') 2065 ? { 2066 pendingClassifierCheck: buildPendingClassifierCheck( 2067 input.command, 2068 appState.toolPermissionContext, 2069 ), 2070 } 2071 : {}), 2072 } 2073 } 2074 2075 return commandOperatorResult 2076 } 2077 2078 // SECURITY: Legacy misparsing gate. Only runs when the tree-sitter module 2079 // is not loaded. Timeout/abort is fail-closed via too-complex (returned 2080 // early above), not routed here. When the AST parse succeeded, 2081 // astSubcommands is non-null and we've already validated structure; this 2082 // block is skipped entirely. The AST's 'too-complex' result subsumes 2083 // everything isBashSecurityCheckForMisparsing covered — both answer the 2084 // same question: "can splitCommand be trusted on this input?" 2085 if ( 2086 astSubcommands === null && 2087 !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_COMMAND_INJECTION_CHECK) 2088 ) { 2089 const originalCommandSafetyResult = await bashCommandIsSafeAsync( 2090 input.command, 2091 ) 2092 if ( 2093 originalCommandSafetyResult.behavior === 'ask' && 2094 originalCommandSafetyResult.isBashSecurityCheckForMisparsing 2095 ) { 2096 // Compound commands with safe heredoc patterns ($(cat <<'EOF'...EOF)) 2097 // trigger the $() check on the unsplit command. Strip the safe heredocs 2098 // and re-check the remainder — if other misparsing patterns exist 2099 // (e.g. backslash-escaped operators), they must still block. 2100 const remainder = stripSafeHeredocSubstitutions(input.command) 2101 const remainderResult = 2102 remainder !== null ? await bashCommandIsSafeAsync(remainder) : null 2103 if ( 2104 remainder === null || 2105 (remainderResult?.behavior === 'ask' && 2106 remainderResult.isBashSecurityCheckForMisparsing) 2107 ) { 2108 // Allow if the exact command has an explicit allow permission — the user 2109 // made a conscious choice to permit this specific command. 2110 appState = context.getAppState() 2111 const exactMatchResult = bashToolCheckExactMatchPermission( 2112 input, 2113 appState.toolPermissionContext, 2114 ) 2115 if (exactMatchResult.behavior === 'allow') { 2116 return exactMatchResult 2117 } 2118 // Attach pending classifier check - may auto-approve before user responds 2119 const decisionReason: PermissionDecisionReason = { 2120 type: 'other' as const, 2121 reason: originalCommandSafetyResult.message, 2122 } 2123 return { 2124 behavior: 'ask', 2125 message: createPermissionRequestMessage( 2126 BashTool.name, 2127 decisionReason, 2128 ), 2129 decisionReason, 2130 suggestions: [], // Don't suggest saving a potentially dangerous command 2131 ...(feature('BASH_CLASSIFIER') 2132 ? { 2133 pendingClassifierCheck: buildPendingClassifierCheck( 2134 input.command, 2135 appState.toolPermissionContext, 2136 ), 2137 } 2138 : {}), 2139 } 2140 } 2141 } 2142 } 2143 2144 // Split into subcommands. Prefer the AST-extracted spans; fall back to 2145 // splitCommand only when tree-sitter was unavailable. The cd-cwd filter 2146 // strips the `cd ${cwd}` prefix that models like to prepend. 2147 const cwd = getCwd() 2148 const cwdMingw = 2149 getPlatform() === 'windows' ? windowsPathToPosixPath(cwd) : cwd 2150 const rawSubcommands = 2151 astSubcommands ?? shadowLegacySubs ?? splitCommand(input.command) 2152 const { subcommands, astCommandsByIdx } = filterCdCwdSubcommands( 2153 rawSubcommands, 2154 astCommands, 2155 cwd, 2156 cwdMingw, 2157 ) 2158 2159 // CC-643: Cap subcommand fanout. Only the legacy splitCommand path can 2160 // explode — the AST path returns a bounded list (astSubcommands !== null) 2161 // or short-circuits to 'too-complex' for structures it can't represent. 2162 if ( 2163 astSubcommands === null && 2164 subcommands.length > MAX_SUBCOMMANDS_FOR_SECURITY_CHECK 2165 ) { 2166 logForDebugging( 2167 `bashPermissions: ${subcommands.length} subcommands exceeds cap (${MAX_SUBCOMMANDS_FOR_SECURITY_CHECK}) — returning ask`, 2168 { level: 'debug' }, 2169 ) 2170 const decisionReason = { 2171 type: 'other' as const, 2172 reason: `Command splits into ${subcommands.length} subcommands, too many to safety-check individually`, 2173 } 2174 return { 2175 behavior: 'ask', 2176 message: createPermissionRequestMessage(BashTool.name, decisionReason), 2177 decisionReason, 2178 } 2179 } 2180 2181 // Ask if there are multiple `cd` commands 2182 const cdCommands = subcommands.filter(subCommand => 2183 isNormalizedCdCommand(subCommand), 2184 ) 2185 if (cdCommands.length > 1) { 2186 const decisionReason = { 2187 type: 'other' as const, 2188 reason: 2189 'Multiple directory changes in one command require approval for clarity', 2190 } 2191 return { 2192 behavior: 'ask', 2193 decisionReason, 2194 message: createPermissionRequestMessage(BashTool.name, decisionReason), 2195 } 2196 } 2197 2198 // Track if compound command contains cd for security validation 2199 // This prevents bypassing path checks via: cd .claude/ && mv test.txt settings.json 2200 const compoundCommandHasCd = cdCommands.length > 0 2201 2202 // SECURITY: Block compound commands that have both cd AND git 2203 // This prevents sandbox escape via: cd /malicious/dir && git status 2204 // where the malicious directory contains a bare git repo with core.fsmonitor. 2205 // This check must happen HERE (before subcommand-level permission checks) 2206 // because bashToolCheckPermission checks each subcommand independently via 2207 // BashTool.isReadOnly(), which would re-derive compoundCommandHasCd=false 2208 // from just "git status" alone, bypassing the readOnlyValidation.ts check. 2209 if (compoundCommandHasCd) { 2210 const hasGitCommand = subcommands.some(cmd => 2211 isNormalizedGitCommand(cmd.trim()), 2212 ) 2213 if (hasGitCommand) { 2214 const decisionReason = { 2215 type: 'other' as const, 2216 reason: 2217 'Compound commands with cd and git require approval to prevent bare repository attacks', 2218 } 2219 return { 2220 behavior: 'ask', 2221 decisionReason, 2222 message: createPermissionRequestMessage(BashTool.name, decisionReason), 2223 } 2224 } 2225 } 2226 2227 appState = context.getAppState() // re-compute the latest in case the user hit shift+tab 2228 2229 // SECURITY FIX: Check Bash deny/ask rules BEFORE path constraints 2230 // This ensures that explicit deny rules like Bash(ls:*) take precedence over 2231 // path constraint checks that return 'ask' for paths outside the project. 2232 // Without this ordering, absolute paths outside the project (e.g., ls /home) 2233 // would bypass deny rules because checkPathConstraints would return 'ask' first. 2234 // 2235 // Note: bashToolCheckPermission calls checkPathConstraints internally, which handles 2236 // output redirection validation on each subcommand. However, since splitCommand strips 2237 // redirections before we get here, we MUST validate output redirections on the ORIGINAL 2238 // command AFTER checking deny rules but BEFORE returning results. 2239 const subcommandPermissionDecisions = subcommands.map((command, i) => 2240 bashToolCheckPermission( 2241 { command }, 2242 appState.toolPermissionContext, 2243 compoundCommandHasCd, 2244 astCommandsByIdx[i], 2245 ), 2246 ) 2247 2248 // Deny if any subcommands are denied 2249 const deniedSubresult = subcommandPermissionDecisions.find( 2250 _ => _.behavior === 'deny', 2251 ) 2252 if (deniedSubresult !== undefined) { 2253 return { 2254 behavior: 'deny', 2255 message: `Permission to use ${BashTool.name} with command ${input.command} has been denied.`, 2256 decisionReason: { 2257 type: 'subcommandResults', 2258 reasons: new Map( 2259 subcommandPermissionDecisions.map((result, i) => [ 2260 subcommands[i]!, 2261 result, 2262 ]), 2263 ), 2264 }, 2265 } 2266 } 2267 2268 // Validate output redirections on the ORIGINAL command (before splitCommand stripped them) 2269 // This must happen AFTER checking deny rules but BEFORE returning results. 2270 // Output redirections like "> /etc/passwd" are stripped by splitCommand, so the per-subcommand 2271 // checkPathConstraints calls won't see them. We validate them here on the original input. 2272 // SECURITY: When AST data is available, pass AST-derived redirects so 2273 // checkPathConstraints uses them directly instead of re-parsing with 2274 // shell-quote (which has a known single-quote backslash misparsing bug 2275 // that can silently hide redirect operators). 2276 const pathResult = checkPathConstraints( 2277 input, 2278 getCwd(), 2279 appState.toolPermissionContext, 2280 compoundCommandHasCd, 2281 astRedirects, 2282 astCommands, 2283 ) 2284 if (pathResult.behavior === 'deny') { 2285 return pathResult 2286 } 2287 2288 const askSubresult = subcommandPermissionDecisions.find( 2289 _ => _.behavior === 'ask', 2290 ) 2291 const nonAllowCount = count( 2292 subcommandPermissionDecisions, 2293 _ => _.behavior !== 'allow', 2294 ) 2295 2296 // SECURITY (GH#28784): Only short-circuit on a path-constraint 'ask' when no 2297 // subcommand independently produced an 'ask'. checkPathConstraints re-runs the 2298 // path-command loop on the full input, so `cd <outside-project> && python3 foo.py` 2299 // produces an ask with ONLY a Read(<dir>/**) suggestion — the UI renders it as 2300 // "Yes, allow reading from <dir>/" and picking that option silently approves 2301 // python3. When a subcommand has its own ask (e.g. the cd subcommand's own 2302 // path-constraint ask), fall through: either the askSubresult short-circuit 2303 // below fires (single non-allow subcommand) or the merge flow collects Bash 2304 // rule suggestions for every non-allow subcommand. The per-subcommand 2305 // checkPathConstraints call inside bashToolCheckPermission already captures 2306 // the Read rule for the cd target in that path. 2307 // 2308 // When no subcommand asked (all allow, or all passthrough like `printf > file`), 2309 // pathResult IS the only ask — return it so redirection checks surface. 2310 if (pathResult.behavior === 'ask' && askSubresult === undefined) { 2311 return pathResult 2312 } 2313 2314 // Ask if any subcommands require approval (e.g., ls/cd outside boundaries). 2315 // Only short-circuit when exactly ONE subcommand needs approval — if multiple 2316 // do (e.g. cd-outside-project ask + python3 passthrough), fall through to the 2317 // merge flow so the prompt surfaces Bash rule suggestions for all of them 2318 // instead of only the first ask's Read rule (GH#28784). 2319 if (askSubresult !== undefined && nonAllowCount === 1) { 2320 return { 2321 ...askSubresult, 2322 ...(feature('BASH_CLASSIFIER') 2323 ? { 2324 pendingClassifierCheck: buildPendingClassifierCheck( 2325 input.command, 2326 appState.toolPermissionContext, 2327 ), 2328 } 2329 : {}), 2330 } 2331 } 2332 2333 // Allow if exact command was allowed 2334 if (exactMatchResult.behavior === 'allow') { 2335 return exactMatchResult 2336 } 2337 2338 // If all subcommands are allowed via exact or prefix match, allow the 2339 // command — but only if no command injection is possible. When the AST 2340 // parse succeeded, each subcommand is already known-safe (no hidden 2341 // substitutions, no structural tricks); the per-subcommand re-check is 2342 // redundant. When on the legacy path, re-run bashCommandIsSafeAsync per sub. 2343 let hasPossibleCommandInjection = false 2344 if ( 2345 astSubcommands === null && 2346 !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_COMMAND_INJECTION_CHECK) 2347 ) { 2348 // CC-643: Batch divergence telemetry into a single logEvent. The per-sub 2349 // logEvent was the hot-path syscall driver (each call → /proc/self/stat 2350 // via process.memoryUsage()). Aggregate count preserves the signal. 2351 let divergenceCount = 0 2352 const onDivergence = () => { 2353 divergenceCount++ 2354 } 2355 const results = await Promise.all( 2356 subcommands.map(c => bashCommandIsSafeAsync(c, onDivergence)), 2357 ) 2358 hasPossibleCommandInjection = results.some( 2359 r => r.behavior !== 'passthrough', 2360 ) 2361 if (divergenceCount > 0) { 2362 logEvent('tengu_tree_sitter_security_divergence', { 2363 quoteContextDivergence: true, 2364 count: divergenceCount, 2365 }) 2366 } 2367 } 2368 if ( 2369 subcommandPermissionDecisions.every(_ => _.behavior === 'allow') && 2370 !hasPossibleCommandInjection 2371 ) { 2372 return { 2373 behavior: 'allow', 2374 updatedInput: input, 2375 decisionReason: { 2376 type: 'subcommandResults', 2377 reasons: new Map( 2378 subcommandPermissionDecisions.map((result, i) => [ 2379 subcommands[i]!, 2380 result, 2381 ]), 2382 ), 2383 }, 2384 } 2385 } 2386 2387 // Query Haiku for command prefixes 2388 // Skip the Haiku call — the UI computes the prefix locally and 2389 // lets the user edit it. Still call when a custom fn is injected (tests). 2390 let commandSubcommandPrefix: Awaited< 2391 ReturnType<typeof getCommandSubcommandPrefixFn> 2392 > = null 2393 if (getCommandSubcommandPrefixFn !== getCommandSubcommandPrefix) { 2394 commandSubcommandPrefix = await getCommandSubcommandPrefixFn( 2395 input.command, 2396 context.abortController.signal, 2397 context.options.isNonInteractiveSession, 2398 ) 2399 if (context.abortController.signal.aborted) { 2400 throw new AbortError() 2401 } 2402 } 2403 2404 // If there is only one command, no need to process subcommands 2405 appState = context.getAppState() // re-compute the latest in case the user hit shift+tab 2406 if (subcommands.length === 1) { 2407 const result = await checkCommandAndSuggestRules( 2408 { command: subcommands[0]! }, 2409 appState.toolPermissionContext, 2410 commandSubcommandPrefix, 2411 compoundCommandHasCd, 2412 astSubcommands !== null, 2413 ) 2414 // If command wasn't allowed, attach pending classifier check. 2415 // At this point, 'ask' can only come from bashCommandIsSafe (security check inside 2416 // checkCommandAndSuggestRules), NOT from explicit ask rules - those were already 2417 // filtered out at step 13 (askSubresult check). The classifier can bypass security. 2418 if (result.behavior === 'ask' || result.behavior === 'passthrough') { 2419 return { 2420 ...result, 2421 ...(feature('BASH_CLASSIFIER') 2422 ? { 2423 pendingClassifierCheck: buildPendingClassifierCheck( 2424 input.command, 2425 appState.toolPermissionContext, 2426 ), 2427 } 2428 : {}), 2429 } 2430 } 2431 return result 2432 } 2433 2434 // Check subcommand permission results 2435 const subcommandResults: Map<string, PermissionResult> = new Map() 2436 for (const subcommand of subcommands) { 2437 subcommandResults.set( 2438 subcommand, 2439 await checkCommandAndSuggestRules( 2440 { 2441 // Pass through input params like `sandbox` 2442 ...input, 2443 command: subcommand, 2444 }, 2445 appState.toolPermissionContext, 2446 commandSubcommandPrefix?.subcommandPrefixes.get(subcommand), 2447 compoundCommandHasCd, 2448 astSubcommands !== null, 2449 ), 2450 ) 2451 } 2452 2453 // Allow if all subcommands are allowed 2454 // Note that this is different than 6b because we are checking the command injection results. 2455 if ( 2456 subcommands.every(subcommand => { 2457 const permissionResult = subcommandResults.get(subcommand) 2458 return permissionResult?.behavior === 'allow' 2459 }) 2460 ) { 2461 // Keep subcommandResults as PermissionResult for decisionReason 2462 return { 2463 behavior: 'allow', 2464 updatedInput: input, 2465 decisionReason: { 2466 type: 'subcommandResults', 2467 reasons: subcommandResults, 2468 }, 2469 } 2470 } 2471 2472 // Otherwise, ask for permission 2473 const collectedRules: Map<string, PermissionRuleValue> = new Map() 2474 2475 for (const [subcommand, permissionResult] of subcommandResults) { 2476 if ( 2477 permissionResult.behavior === 'ask' || 2478 permissionResult.behavior === 'passthrough' 2479 ) { 2480 const updates = 2481 'suggestions' in permissionResult 2482 ? permissionResult.suggestions 2483 : undefined 2484 2485 const rules = extractRules(updates) 2486 for (const rule of rules) { 2487 // Use string representation as key for deduplication 2488 const ruleKey = permissionRuleValueToString(rule) 2489 collectedRules.set(ruleKey, rule) 2490 } 2491 2492 // GH#28784 follow-up: security-check asks (compound-cd+write, process 2493 // substitution, etc.) carry no suggestions. In a compound command like 2494 // `cd ~/out && rm -rf x`, that means only cd's Read rule gets collected 2495 // and the UI labels the prompt "Yes, allow reading from <dir>/" — never 2496 // mentioning rm. Synthesize a Bash(exact) rule so the UI shows the 2497 // chained command. Skip explicit ask rules (decisionReason.type 'rule') 2498 // where the user deliberately wants to review each time. 2499 if ( 2500 permissionResult.behavior === 'ask' && 2501 rules.length === 0 && 2502 permissionResult.decisionReason?.type !== 'rule' 2503 ) { 2504 for (const rule of extractRules( 2505 suggestionForExactCommand(subcommand), 2506 )) { 2507 const ruleKey = permissionRuleValueToString(rule) 2508 collectedRules.set(ruleKey, rule) 2509 } 2510 } 2511 // Note: We only collect rules, not other update types like mode changes 2512 // This is appropriate for bash subcommands which primarily need rule suggestions 2513 } 2514 } 2515 2516 const decisionReason = { 2517 type: 'subcommandResults' as const, 2518 reasons: subcommandResults, 2519 } 2520 2521 // GH#11380: Cap at MAX_SUGGESTED_RULES_FOR_COMPOUND. Map preserves insertion 2522 // order (subcommand order), so slicing keeps the leftmost N. 2523 const cappedRules = Array.from(collectedRules.values()).slice( 2524 0, 2525 MAX_SUGGESTED_RULES_FOR_COMPOUND, 2526 ) 2527 const suggestedUpdates: PermissionUpdate[] | undefined = 2528 cappedRules.length > 0 2529 ? [ 2530 { 2531 type: 'addRules', 2532 rules: cappedRules, 2533 behavior: 'allow', 2534 destination: 'localSettings', 2535 }, 2536 ] 2537 : undefined 2538 2539 // Attach pending classifier check - may auto-approve before user responds. 2540 // Behavior is 'ask' if any subcommand was 'ask' (e.g., path constraint or ask 2541 // rule) — before the GH#28784 fix, ask subresults always short-circuited above 2542 // so this path only saw 'passthrough' subcommands and hardcoded that. 2543 return { 2544 behavior: askSubresult !== undefined ? 'ask' : 'passthrough', 2545 message: createPermissionRequestMessage(BashTool.name, decisionReason), 2546 decisionReason, 2547 suggestions: suggestedUpdates, 2548 ...(feature('BASH_CLASSIFIER') 2549 ? { 2550 pendingClassifierCheck: buildPendingClassifierCheck( 2551 input.command, 2552 appState.toolPermissionContext, 2553 ), 2554 } 2555 : {}), 2556 } 2557} 2558 2559/** 2560 * Checks if a subcommand is a git command after normalizing away safe wrappers 2561 * (env vars, timeout, etc.) and shell quotes. 2562 * 2563 * SECURITY: Must normalize before matching to prevent bypasses like: 2564 * 'git' status — shell quotes hide the command from a naive regex 2565 * NO_COLOR=1 git status — env var prefix hides the command 2566 */ 2567export function isNormalizedGitCommand(command: string): boolean { 2568 // Fast path: catch the most common case before any parsing 2569 if (command.startsWith('git ') || command === 'git') { 2570 return true 2571 } 2572 const stripped = stripSafeWrappers(command) 2573 const parsed = tryParseShellCommand(stripped) 2574 if (parsed.success && parsed.tokens.length > 0) { 2575 // Direct git command 2576 if (parsed.tokens[0] === 'git') { 2577 return true 2578 } 2579 // "xargs git ..." — xargs runs git in the current directory, 2580 // so it must be treated as a git command for cd+git security checks. 2581 // This matches the xargs prefix handling in filterRulesByContentsMatchingInput. 2582 if (parsed.tokens[0] === 'xargs' && parsed.tokens.includes('git')) { 2583 return true 2584 } 2585 return false 2586 } 2587 return /^git(?:\s|$)/.test(stripped) 2588} 2589 2590/** 2591 * Checks if a subcommand is a cd command after normalizing away safe wrappers 2592 * (env vars, timeout, etc.) and shell quotes. 2593 * 2594 * SECURITY: Must normalize before matching to prevent bypasses like: 2595 * FORCE_COLOR=1 cd sub — env var prefix hides the cd from a naive /^cd / regex 2596 * This mirrors isNormalizedGitCommand to ensure symmetric normalization. 2597 * 2598 * Also matches pushd/popd — they change cwd just like cd, so 2599 * pushd /tmp/bare-repo && git status 2600 * must trigger the same cd+git guard. Mirrors PowerShell's 2601 * DIRECTORY_CHANGE_ALIASES (src/utils/powershell/parser.ts). 2602 */ 2603export function isNormalizedCdCommand(command: string): boolean { 2604 const stripped = stripSafeWrappers(command) 2605 const parsed = tryParseShellCommand(stripped) 2606 if (parsed.success && parsed.tokens.length > 0) { 2607 const cmd = parsed.tokens[0] 2608 return cmd === 'cd' || cmd === 'pushd' || cmd === 'popd' 2609 } 2610 return /^(?:cd|pushd|popd)(?:\s|$)/.test(stripped) 2611} 2612 2613/** 2614 * Checks if a compound command contains any cd command, 2615 * using normalized detection that handles env var prefixes and shell quotes. 2616 */ 2617export function commandHasAnyCd(command: string): boolean { 2618 return splitCommand(command).some(subcmd => 2619 isNormalizedCdCommand(subcmd.trim()), 2620 ) 2621}