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