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