bashPermissions.ts 97 KB

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