/ tools / BashTool / bashPermissions.ts
bashPermissions.ts
   1  import { feature } from 'bun:bundle'
   2  import { APIUserAbortError } from '@anthropic-ai/sdk'
   3  import type { z } from 'zod/v4'
   4  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
   5  import {
   6    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
   7    logEvent,
   8  } from '../../services/analytics/index.js'
   9  import type { ToolPermissionContext, ToolUseContext } from '../../Tool.js'
  10  import type { PendingClassifierCheck } from '../../types/permissions.js'
  11  import { count } from '../../utils/array.js'
  12  import {
  13    checkSemantics,
  14    nodeTypeId,
  15    type ParseForSecurityResult,
  16    parseForSecurityFromAst,
  17    type Redirect,
  18    type SimpleCommand,
  19  } from '../../utils/bash/ast.js'
  20  import {
  21    type CommandPrefixResult,
  22    extractOutputRedirections,
  23    getCommandSubcommandPrefix,
  24    splitCommand_DEPRECATED,
  25  } from '../../utils/bash/commands.js'
  26  import { parseCommandRaw } from '../../utils/bash/parser.js'
  27  import { tryParseShellCommand } from '../../utils/bash/shellQuote.js'
  28  import { getCwd } from '../../utils/cwd.js'
  29  import { logForDebugging } from '../../utils/debug.js'
  30  import { isEnvTruthy } from '../../utils/envUtils.js'
  31  import { AbortError } from '../../utils/errors.js'
  32  import type {
  33    ClassifierBehavior,
  34    ClassifierResult,
  35  } from '../../utils/permissions/bashClassifier.js'
  36  import {
  37    classifyBashCommand,
  38    getBashPromptAllowDescriptions,
  39    getBashPromptAskDescriptions,
  40    getBashPromptDenyDescriptions,
  41    isClassifierPermissionsEnabled,
  42  } from '../../utils/permissions/bashClassifier.js'
  43  import type {
  44    PermissionDecisionReason,
  45    PermissionResult,
  46  } from '../../utils/permissions/PermissionResult.js'
  47  import type {
  48    PermissionRule,
  49    PermissionRuleValue,
  50  } from '../../utils/permissions/PermissionRule.js'
  51  import { extractRules } from '../../utils/permissions/PermissionUpdate.js'
  52  import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js'
  53  import { permissionRuleValueToString } from '../../utils/permissions/permissionRuleParser.js'
  54  import {
  55    createPermissionRequestMessage,
  56    getRuleByContentsForTool,
  57  } from '../../utils/permissions/permissions.js'
  58  import {
  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'
  66  import { getPlatform } from '../../utils/platform.js'
  67  import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
  68  import { jsonStringify } from '../../utils/slowOperations.js'
  69  import { windowsPathToPosixPath } from '../../utils/windowsPaths.js'
  70  import { BashTool } from './BashTool.js'
  71  import { checkCommandOperatorPermissions } from './bashCommandHelpers.js'
  72  import {
  73    bashCommandIsSafeAsync_DEPRECATED,
  74    stripSafeHeredocSubstitutions,
  75  } from './bashSecurity.js'
  76  import { checkPermissionMode } from './modeValidation.js'
  77  import { checkPathConstraints } from './pathValidation.js'
  78  import { checkSedConstraints } from './sedValidation.js'
  79  import { 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.)
  88  const bashCommandIsSafeAsync = bashCommandIsSafeAsync_DEPRECATED
  89  const 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.
  93  const 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).
 103  export 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.
 110  export 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   */
 117  function 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   */
 161  export 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.
 196  const 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   */
 243  export 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  
 266  function 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   */
 307  function 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  
 339  function 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   */
 347  export const permissionRuleExtractPrefix = sharedPermissionRuleExtractPrefix
 348  
 349  /**
 350   * Match a command against a wildcard pattern (case-sensitive for Bash).
 351   * Delegates to shared implementation.
 352   */
 353  export 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   */
 364  export 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   */
 378  const 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   */
 447  const 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   */
 508  function 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  
 524  export 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.
 620  const 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   */
 633  function 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   */
 678  export 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   */
 708  export 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   */
 733  export 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  
 778  function 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  
 937  function 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   */
 991  export 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  
1050  export 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   */
1183  export 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   */
1270  function 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   */
1367  function 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   */
1391  function 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   */
1431  function 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   */
1459  function 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  
1483  const 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   */
1491  export function peekSpeculativeClassifierCheck(
1492    command: string,
1493  ): Promise<ClassifierResult> | undefined {
1494    return speculativeChecks.get(command)
1495  }
1496  
1497  export 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   */
1533  export 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  
1543  export 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   */
1555  export 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  
1589  type 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   */
1605  export 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   */
1663  export 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   */
2567  export 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   */
2603  export 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   */
2617  export function commandHasAnyCd(command: string): boolean {
2618    return splitCommand(command).some(subcmd =>
2619      isNormalizedCdCommand(subcmd.trim()),
2620    )
2621  }