Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
99 kB · 2621 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622import { 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_DEPRECATEDconst 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()), )}