本章总览
tools/BashTool/(18 个文件)实现 Claude Code 最高频工具:在 user shell 中执行命令,贯穿权限 AST 解析、沙箱、只读判定、sed 模拟编辑、后台任务与 transcript 折叠显示。BashTool.tsx 通过 buildTool 导出 Bash;bashPermissions.ts(2600+ 行)实现 bashToolHasPermission 与分类器集成。本章要求你能从一条 Bash tool_use 追踪到 exec 调用、权限 deny 原因、以及 stdout 如何变成 model-facing tool_result。
学完本章你应该能
- 解释 isReadOnly / isConcurrencySafe 与 checkReadOnlyConstraints 的关系
- 说明 bashToolHasPermission 与 permissions.ts 规则引擎的分工
- 理解 isSearchOrReadBashCommand 如何驱动 UI 折叠
- 掌握 run_in_background、沙箱、sed 模拟编辑三条特殊路径
- 能在 StreamingToolExecutor 中定位 Bash 错误级联 sibling abort 逻辑
核心概念(先读懂这些)
Bash 权限是两层叠加
permissions.ts 的 deny/ask 规则先匹配工具名 Bash(pattern);bashToolHasPermission 再解析命令 AST,检查路径、sed、operator、沙箱 auto-allow 等。分类器(auto 模式)在 bashPermissions 内启动 speculative check。Hook 的 Bash(git *) 模式依赖 preparePermissionMatcher 拆分子命令。
只读命令可并发
isConcurrencySafe 委托 isReadOnly:通过 checkReadOnlyConstraints 的 compound 命令分析,纯 cat/grep/find 管道可与其他只读 Bash 并行。写操作或含 cd 的 compound 串行。StreamingToolExecutor 据此调度;Bash 错误会 abort siblingAbortController 取消并行 Bash。
模型 payload 与 UI 分离
mapToolResultToToolResultBlockParam 可注入 persisted-output 包装、backgroundInfo;renderToolResultMessage / extractSearchText 使用原始 stdout。注释强调 UI never 见 persistedOutputPath wrapper,避免 transcript 与模型上下文不一致。
建议学习步骤
- 阅读 buildTool 导出块:权限与并发钩子
- 阅读 isSearchOrReadBashCommand 折叠逻辑
- 阅读 call() 入口与 runShellCommand 生成器
- 阅读 bashToolHasPermission 入口
- 阅读 preparePermissionMatcher 与 compound 命令
- 对照 UI.tsx 渲染与 extractSearchText
常见误区
注意
userFacingName 内避免重 parse(#21605 shimmer 死循环);沙箱指示器用 env 门控
注意
sed 模拟编辑 _simulatedSedEdit 路径绕过 shell,直接写文件
注意
BASH_TOOL_NAME 常量勿与权限规则硬编码字符串分叉
目录结构与职责
BashTool/ 核心文件:
| 文件 | 职责 |
|---|---|
| BashTool.tsx | buildTool 主实现:call、权限钩子、结果映射 |
| bashPermissions.ts | bashToolHasPermission、分类器、规则匹配 |
| bashSecurity.ts | 命令安全启发式(legacy) |
| readOnlyValidation.ts | 只读 compound 分析 |
| shouldUseSandbox.ts | 沙箱决策 |
| sedEditParser.ts / sedValidation.ts | sed -i 模拟为 FileEdit |
| UI.tsx | renderToolUseMessage、进度、结果折叠 |
| prompt.ts | 工具 description 与 timeout 常量 |
| toolName.ts | BASH_TOOL_NAME = 'Bash' |
Bash 是 strict: true 工具,且 toAutoClassifierInput 直接返回 command 字符串供 auto 模式分类器消费。
buildTool 导出:并发与权限钩子
BashTool 在 buildTool({...}) 里把「能不能并行」和「能不能执行」拆成两条链路。并发侧,isReadOnly 调用 checkReadOnlyConstraints,对 compound 命令做 AST/规则分析:纯 grep|cat|ls 管道可并行,含写 redirect、cd 出项目根、或 destructive 子命令则只读失败。执行权限侧,checkPermissions 指向 bashToolHasPermission,它会叠加权限规则、路径约束、沙箱、分类器与 hook matcher。
关键配置:
- maxResultSizeChars: 30_000 — 超出则 toolResultStorage 落盘
- isConcurrencySafe →
isReadOnly(input) - checkPermissions →
bashToolHasPermission - preparePermissionMatcher — 拆 compound 命令匹配 hook 模式
- isSearchOrReadCommand — 委托
isSearchOrReadBashCommand - validateInput — MONITOR_TOOL 下拦截 sleep 模式
preparePermissionMatcher 会把 ls && git push 拆成子命令,避免 hook 模式 Bash(git *) 被 compound 绕过。parse 失败时 fail-safe 返回 () => true,保证安全 hook 不会因为解析器异常而失效。
源码引用: src/tools/BashTool/BashTool.tsx · 第 640–720 行(共 1473 行)
640| export const BashTool = buildTool({
641| name: BASH_TOOL_NAME,
642| searchHint: 'execute shell commands',
643| // 30K chars - tool result persistence threshold
644| maxResultSizeChars: 30_000,
645| strict: true,
646| async description({ description }) {
647| return description || 'Run shell command'
648| },
649| async prompt() {
650| return getSimplePrompt()
651| },
652| isConcurrencySafe(input) {
653| return this.isReadOnly?.(input) ?? false
654| },
655| isReadOnly(input) {
656| const compoundCommandHasCd = commandHasAnyCd(input.command)
657| const result = checkReadOnlyConstraints(input, compoundCommandHasCd)
658| return result.behavior === 'allow'
659| },
660| toAutoClassifierInput(input) {
661| return input.command
662| },
663| async preparePermissionMatcher({ command }) {
664| // Hook `if` filtering is "no match → skip hook" (deny-like semantics), so
665| // compound commands must fire the hook if ANY subcommand matches. Without
666| // splitting, `ls && git push` would bypass a `Bash(git *)` security hook.
667| const parsed = await parseForSecurity(command)
668| if (parsed.kind !== 'simple') {
669| // parse-unavailable / too-complex: fail safe by running the hook.
670| return () => true
671| }
672| // Match on argv (strips leading VAR=val) so `FOO=bar git push` still
673| // matches `Bash(git *)`.
674| const subcommands = parsed.commands.map(c => c.argv.join(' '))
675| return pattern => {
676| const prefix = permissionRuleExtractPrefix(pattern)
677| return subcommands.some(cmd => {
678| if (prefix !== null) {
679| return cmd === prefix || cmd.startsWith(`${prefix} `)
680| }
681| return matchWildcardPattern(pattern, cmd)
682| })
683| }
684| },
685| isSearchOrReadCommand(input) {
686| const parsed = inputSchema().safeParse(input)
687| if (!parsed.success)
688| return { isSearch: false, isRead: false, isList: false }
689| return isSearchOrReadBashCommand(parsed.data.command)
690| },
691| get inputSchema(): InputSchema {
692| return inputSchema()
693| },
694| get outputSchema(): OutputSchema {
695| return outputSchema()
696| },
697| userFacingName(input) {
698| if (!input) {
699| return 'Bash'
700| }
701| // Render sed in-place edits as file edits
702| if (input.command) {
703| const sedInfo = parseSedEditCommand(input.command)
704| if (sedInfo) {
705| return fileEditUserFacingName({
706| file_path: sedInfo.filePath,
707| old_string: 'x',
708| })
709| }
710| }
711| // Env var FIRST: shouldUseSandbox → splitCommand_DEPRECATED → shell-quote's
712| // `new RegExp` per call. userFacingName runs per-render for every bash
713| // message in history; with ~50 msgs + one slow-to-tokenize command, this
714| // exceeds the shimmer tick → transition abort → infinite retry (#21605).
715| return isEnvTruthy(process.env.CLAUDE_CODE_BASH_SANDBOX_SHOW_INDICATOR) &&
716| shouldUseSandbox(input)
717| ? 'SandboxedBash'
718| : 'Bash'
719| },
720| getToolUseSummary(input) {
源码引用: src/tools/BashTool/BashTool.tsx · 第 198–267 行(共 1473 行)
198| export function isSearchOrReadBashCommand(command: string): {
199| isSearch: boolean
200| isRead: boolean
201| isList: boolean
202| } {
203| let partsWithOperators: string[]
204| try {
205| partsWithOperators = splitCommandWithOperators(command)
206| } catch {
207| // If we can't parse the command due to malformed syntax,
208| // it's not a search/read command
209| return { isSearch: false, isRead: false, isList: false }
210| }
211|
212| if (partsWithOperators.length === 0) {
213| return { isSearch: false, isRead: false, isList: false }
214| }
215|
216| let hasSearch = false
217| let hasRead = false
218| let hasList = false
219| let hasNonNeutralCommand = false
220| let skipNextAsRedirectTarget = false
221|
222| for (const part of partsWithOperators) {
223| if (skipNextAsRedirectTarget) {
224| skipNextAsRedirectTarget = false
225| continue
226| }
227|
228| if (part === '>' || part === '>>' || part === '>&') {
229| skipNextAsRedirectTarget = true
230| continue
231| }
232|
233| if (part === '||' || part === '&&' || part === '|' || part === ';') {
234| continue
235| }
236|
237| const baseCommand = part.trim().split(/\s+/)[0]
238| if (!baseCommand) {
239| continue
240| }
241|
242| if (BASH_SEMANTIC_NEUTRAL_COMMANDS.has(baseCommand)) {
243| continue
244| }
245|
246| hasNonNeutralCommand = true
247|
248| const isPartSearch = BASH_SEARCH_COMMANDS.has(baseCommand)
249| const isPartRead = BASH_READ_COMMANDS.has(baseCommand)
250| const isPartList = BASH_LIST_COMMANDS.has(baseCommand)
251|
252| if (!isPartSearch && !isPartRead && !isPartList) {
253| return { isSearch: false, isRead: false, isList: false }
254| }
255|
256| if (isPartSearch) hasSearch = true
257| if (isPartRead) hasRead = true
258| if (isPartList) hasList = true
259| }
260|
261| // Only neutral commands (e.g., just "echo foo") -- not collapsible
262| if (!hasNonNeutralCommand) {
263| return { isSearch: false, isRead: false, isList: false }
264| }
265|
266| return { isSearch: hasSearch, isRead: hasRead, isList: hasList }
267| }
isSearchOrReadBashCommand:UI 折叠
折叠显示(condensed transcript)依赖 isSearchOrReadBashCommand:
命令集合:
- BASH_SEARCH_COMMANDS — find、grep、rg 等
- BASH_READ_COMMANDS — cat、head、jq、awk 等
- BASH_LIST_COMMANDS — ls、tree、du(单独 isList 语义)
- BASH_SEMANTIC_NEUTRAL_COMMANDS — echo、true 等可跳过
规则: splitCommandWithOperators 解析失败 → 不折叠。pipeline 中任一部分非 search/read/list → 整体不折叠。仅 neutral 命令(如单独 echo)也不折叠。
这与 Tool 接口 isSearchOrReadCommand 挂钩,Message 组件据此合并多行 Bash 输出,减少 transcript 噪音。
源码引用: src/tools/BashTool/BashTool.tsx · 第 59–78 行(共 1473 行)
59| import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'
60| import { maybeRecordPluginHint } from '../../utils/plugins/hintRecommendation.js'
61| import { exec } from '../../utils/Shell.js'
62| import type { ExecResult } from '../../utils/ShellCommand.js'
63| import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
64| import { semanticBoolean } from '../../utils/semanticBoolean.js'
65| import { semanticNumber } from '../../utils/semanticNumber.js'
66| import { EndTruncatingAccumulator } from '../../utils/stringUtils.js'
67| import { getTaskOutputPath } from '../../utils/task/diskOutput.js'
68| import { TaskOutput } from '../../utils/task/TaskOutput.js'
69| import { isOutputLineTruncated } from '../../utils/terminal.js'
70| import {
71| buildLargeToolResultMessage,
72| ensureToolResultsDir,
73| generatePreview,
74| getToolResultPath,
75| PREVIEW_SIZE_BYTES,
76| } from '../../utils/toolResultStorage.js'
77| import { userFacingName as fileEditUserFacingName } from '../FileEditTool/UI.js'
78| import { trackGitOperations } from '../shared/gitOperationTracking.js'
源码引用: src/tools/BashTool/BashTool.tsx · 第 95–172 行(共 1473 行)
95| import {
96| BackgroundHint,
97| renderToolResultMessage,
98| renderToolUseErrorMessage,
99| renderToolUseMessage,
100| renderToolUseProgressMessage,
101| renderToolUseQueuedMessage,
102| } from './UI.js'
103| import {
104| buildImageToolResult,
105| isImageOutput,
106| resetCwdIfOutsideProject,
107| resizeShellImageOutput,
108| stdErrAppendShellResetMessage,
109| stripEmptyLines,
110| } from './utils.js'
111|
112| const EOL = '\n'
113|
114| // Progress display constants
115| const PROGRESS_THRESHOLD_MS = 2000 // Show progress after 2 seconds
116| // In assistant mode, blocking bash auto-backgrounds after this many ms in the main agent
117| const ASSISTANT_BLOCKING_BUDGET_MS = 15_000
118|
119| // Search commands for collapsible display (grep, find, etc.)
120| const BASH_SEARCH_COMMANDS = new Set([
121| 'find',
122| 'grep',
123| 'rg',
124| 'ag',
125| 'ack',
126| 'locate',
127| 'which',
128| 'whereis',
129| ])
130|
131| // Read/view commands for collapsible display (cat, head, etc.)
132| const BASH_READ_COMMANDS = new Set([
133| 'cat',
134| 'head',
135| 'tail',
136| 'less',
137| 'more',
138| // Analysis commands
139| 'wc',
140| 'stat',
141| 'file',
142| 'strings',
143| // Data processing — commonly used to parse/transform file content in pipes
144| 'jq',
145| 'awk',
146| 'cut',
147| 'sort',
148| 'uniq',
149| 'tr',
150| ])
151|
152| // Directory-listing commands for collapsible display (ls, tree, du).
153| // Split from BASH_READ_COMMANDS so the summary says "Listed N directories"
154| // instead of the misleading "Read N files".
155| const BASH_LIST_COMMANDS = new Set(['ls', 'tree', 'du'])
156|
157| // Commands that are semantic-neutral in any position — pure output/status commands
158| // that don't change the read/search nature of the overall pipeline.
159| // e.g. `ls dir && echo "---" && ls dir2` is still a read-only compound command.
160| const BASH_SEMANTIC_NEUTRAL_COMMANDS = new Set([
161| 'echo',
162| 'printf',
163| 'true',
164| 'false',
165| ':', // bash no-op
166| ])
167|
168| // Commands that typically produce no stdout on success
169| const BASH_SILENT_COMMANDS = new Set([
170| 'mv',
171| 'cp',
172| 'rm',
call():执行主路径
call() 的第一分支是 _simulatedSedEdit:权限预览里 sed -i 已被解析为文件补丁,执行时不再起 shell,而是 applySedEdit 直接写盘,保证「用户看到的 diff = 实际写入」。常规路径走 runShellCommand 异步生成器,stdout/stderr 经 EndTruncatingAccumulator 截断,并通过 onProgress 推给 Executor。
几个特殊路径要一起读:
- preventCwdChanges:子 agent(
agentId存在)禁止cd污染父会话 cwd - shouldUseSandbox:满足沙箱条件时走
SandboxManager - run_in_background:注册
LocalShellTask,result 里带backgroundTaskId - assistant auto-background:阻塞命令超过预算后转后台
- abortController:来自 StreamingToolExecutor 的 per-tool child controller,Bash 子进程监听 abort signal
Bash 一旦返回 is_error,StreamingToolExecutor 会取消并行 Bash sibling;Read/WebFetch 这类只读工具失败不会触发同样级联。
源码引用: src/tools/BashTool/BashTool.tsx · 第 846–950 行(共 1473 行)
846| async call(
847| input: BashToolInput,
848| toolUseContext,
849| _canUseTool?: CanUseToolFn,
850| parentMessage?: AssistantMessage,
851| onProgress?: ToolCallProgress<BashProgress>,
852| ) {
853| // Handle simulated sed edit - apply directly instead of running sed
854| // This ensures what the user previewed is exactly what gets written
855| if (input._simulatedSedEdit) {
856| return applySedEdit(
857| input._simulatedSedEdit,
858| toolUseContext,
859| parentMessage,
860| )
861| }
862|
863| const { abortController, getAppState, setAppState, setToolJSX } =
864| toolUseContext
865|
866| const stdoutAccumulator = new EndTruncatingAccumulator()
867| let stderrForShellReset = ''
868| let interpretationResult:
869| | ReturnType<typeof interpretCommandResult>
870| | undefined
871|
872| let progressCounter = 0
873| let wasInterrupted = false
874| let result: ExecResult
875|
876| const isMainThread = !toolUseContext.agentId
877| const preventCwdChanges = !isMainThread
878|
879| try {
880| // Use the new async generator version of runShellCommand
881| const commandGenerator = runShellCommand({
882| input,
883| abortController,
884| // Use the always-shared task channel so async agents' background
885| // bash tasks are actually registered (and killable on agent exit).
886| setAppState: toolUseContext.setAppStateForTasks ?? setAppState,
887| setToolJSX,
888| preventCwdChanges,
889| isMainThread,
890| toolUseId: toolUseContext.toolUseId,
891| agentId: toolUseContext.agentId,
892| })
893|
894| // Consume the generator and capture the return value
895| let generatorResult
896| do {
897| generatorResult = await commandGenerator.next()
898| if (!generatorResult.done && onProgress) {
899| const progress = generatorResult.value
900| onProgress({
901| toolUseID: `bash-progress-${progressCounter++}`,
902| data: {
903| type: 'bash_progress',
904| output: progress.output,
905| fullOutput: progress.fullOutput,
906| elapsedTimeSeconds: progress.elapsedTimeSeconds,
907| totalLines: progress.totalLines,
908| totalBytes: progress.totalBytes,
909| taskId: progress.taskId,
910| timeoutMs: progress.timeoutMs,
911| },
912| })
913| }
914| } while (!generatorResult.done)
915|
916| // Get the final result from the generator's return value
917| result = generatorResult.value
918|
919| trackGitOperations(input.command, result.code, result.stdout)
920|
921| const isInterrupt =
922| result.interrupted && abortController.signal.reason === 'interrupt'
923|
924| // stderr is interleaved in stdout (merged fd) — result.stdout has both
925| stdoutAccumulator.append((result.stdout || '').trimEnd() + EOL)
926|
927| // Interpret the command result using semantic rules
928| interpretationResult = interpretCommandResult(
929| input.command,
930| result.code,
931| result.stdout || '',
932| '',
933| )
934|
935| // Check for git index.lock error (stderr is in stdout now)
936| if (
937| result.stdout &&
938| result.stdout.includes(".git/index.lock': File exists")
939| ) {
940| logEvent('tengu_git_index_lock_error', {})
941| }
942|
943| if (interpretationResult.isError && !isInterrupt) {
944| // Only add exit code if it's actually an error
945| if (result.code !== 0) {
946| stdoutAccumulator.append(`Exit code ${result.code}`)
947| }
948| }
949|
950| if (!preventCwdChanges) {
源码引用: src/services/tools/StreamingToolExecutor.ts · 第 354–364 行(共 531 行)
354| if (isErrorResult) {
355| thisToolErrored = true
356| // Only Bash errors cancel siblings. Bash commands often have implicit
357| // dependency chains (e.g. mkdir fails → subsequent commands pointless).
358| // Read/WebFetch/etc are independent — one failure shouldn't nuke the rest.
359| if (tool.block.name === BASH_TOOL_NAME) {
360| this.hasErrored = true
361| this.erroredToolDescription = this.getToolDescription(tool)
362| this.siblingAbortController.abort('sibling_error')
363| }
364| }
mapToolResultToToolResultBlockParam
结果序列化处理多种形态:
- structuredContent — 直接作为 content array
- isImage — buildImageToolResult 转 image block
- persistedOutputPath — buildLargeToolResultMessage 包装(仅模型见)
- interrupted — stderr 追加 abort XML
- backgroundTaskId — backgroundInfo 文案区分 user/assistant/auto background
extractSearchText 合并 stdout+stderr 供 transcript 搜索索引;与 renderToolResultMessage 可见文本应对齐(renderFidelity 测试约束)。
UI 使用 BashToolResultMessage 的 OutputLine 组件;never 展示 persisted 包装层。
源码引用: src/tools/BashTool/BashTool.tsx · 第 768–845 行(共 1473 行)
768| mapToolResultToToolResultBlockParam(
769| {
770| interrupted,
771| stdout,
772| stderr,
773| isImage,
774| backgroundTaskId,
775| backgroundedByUser,
776| assistantAutoBackgrounded,
777| structuredContent,
778| persistedOutputPath,
779| persistedOutputSize,
780| },
781| toolUseID,
782| ): ToolResultBlockParam {
783| // Handle structured content
784| if (structuredContent && structuredContent.length > 0) {
785| return {
786| tool_use_id: toolUseID,
787| type: 'tool_result',
788| content: structuredContent,
789| }
790| }
791|
792| // For image data, format as image content block for Claude
793| if (isImage) {
794| const block = buildImageToolResult(stdout, toolUseID)
795| if (block) return block
796| }
797|
798| let processedStdout = stdout
799| if (stdout) {
800| // Replace any leading newlines or lines with only whitespace
801| processedStdout = stdout.replace(/^(\s*\n)+/, '')
802| // Still trim the end as before
803| processedStdout = processedStdout.trimEnd()
804| }
805|
806| // For large output that was persisted to disk, build <persisted-output>
807| // message for the model. The UI never sees this — it uses data.stdout.
808| if (persistedOutputPath) {
809| const preview = generatePreview(processedStdout, PREVIEW_SIZE_BYTES)
810| processedStdout = buildLargeToolResultMessage({
811| filepath: persistedOutputPath,
812| originalSize: persistedOutputSize ?? 0,
813| isJson: false,
814| preview: preview.preview,
815| hasMore: preview.hasMore,
816| })
817| }
818|
819| let errorMessage = stderr.trim()
820| if (interrupted) {
821| if (stderr) errorMessage += EOL
822| errorMessage += '<error>Command was aborted before completion</error>'
823| }
824|
825| let backgroundInfo = ''
826| if (backgroundTaskId) {
827| const outputPath = getTaskOutputPath(backgroundTaskId)
828| if (assistantAutoBackgrounded) {
829| backgroundInfo = `Command exceeded the assistant-mode blocking budget (${ASSISTANT_BLOCKING_BUDGET_MS / 1000}s) and was moved to the background with ID: ${backgroundTaskId}. It is still running — you will be notified when it completes. Output is being written to: ${outputPath}. In assistant mode, delegate long-running work to a subagent or use run_in_background to keep this conversation responsive.`
830| } else if (backgroundedByUser) {
831| backgroundInfo = `Command was manually backgrounded by user with ID: ${backgroundTaskId}. Output is being written to: ${outputPath}`
832| } else {
833| backgroundInfo = `Command running in background with ID: ${backgroundTaskId}. Output is being written to: ${outputPath}`
834| }
835| }
836|
837| return {
838| tool_use_id: toolUseID,
839| type: 'tool_result',
840| content: [processedStdout, errorMessage, backgroundInfo]
841| .filter(Boolean)
842| .join('\n'),
843| is_error: interrupted,
844| }
845| },
bashToolHasPermission 入口
bashPermissions.ts 的 bashToolHasPermission(约 1663 行)是 Bash 特有权限大脑,在 permissions.ts 规则匹配之后调用。
典型检查链(读文件继续):
- checkPermissionMode — plan/auto 模式约束
- parseForSecurity / AST — 命令语义
- checkPathConstraints — 工作目录与额外目录
- checkSedConstraints — sed -i 转 FileEdit 权限
- checkCommandOperatorPermissions — && || 管道组合
- shouldUseSandbox + SandboxManager — 沙箱 auto-allow
- classifyBashCommand — auto 模式分类器
- startSpeculativeClassifierCheck — 权限弹窗等待时并行分类
返回 PermissionResult:allow / deny / ask。deny message 常含可操作建议(添加 Bash(pattern) 规则)。
体量大原因: 需覆盖 Windows path、heredoc、wildcard 规则、PowerShell 共存等边界。
源码引用: src/tools/BashTool/bashPermissions.ts · 第 1663–1720 行(共 2622 行)
1663| export async function bashToolHasPermission(
1664| input: z.infer<typeof BashTool.inputSchema>,
1665| context: ToolUseContext,
1666| getCommandSubcommandPrefixFn = getCommandSubcommandPrefix,
1667| ): Promise<PermissionResult> {
1668| let appState = context.getAppState()
1669|
1670| // 0. AST-based security parse. This replaces both tryParseShellCommand
1671| // (the shell-quote pre-check) and the bashCommandIsSafe misparsing gate.
1672| // tree-sitter produces either a clean SimpleCommand[] (quotes resolved,
1673| // no hidden substitutions) or 'too-complex' — which is exactly the signal
1674| // we need to decide whether splitCommand's output can be trusted.
1675| //
1676| // When tree-sitter WASM is unavailable OR the injection check is disabled
1677| // via env var, we fall back to the old path (legacy gate at ~1370 runs).
1678| const injectionCheckDisabled = isEnvTruthy(
1679| process.env.CLAUDE_CODE_DISABLE_COMMAND_INJECTION_CHECK,
1680| )
1681| // GrowthBook killswitch for shadow mode — when off, skip the native parse
1682| // entirely. Computed once; feature() must stay inline in the ternary below.
1683| const shadowEnabled = feature('TREE_SITTER_BASH_SHADOW')
1684| ? getFeatureValue_CACHED_MAY_BE_STALE('tengu_birch_trellis', true)
1685| : false
1686| // Parse once here; the resulting AST feeds both parseForSecurityFromAst
1687| // and bashToolCheckCommandOperatorPermissions.
1688| let astRoot = injectionCheckDisabled
1689| ? null
1690| : feature('TREE_SITTER_BASH_SHADOW') && !shadowEnabled
1691| ? null
1692| : await parseCommandRaw(input.command)
1693| let astResult: ParseForSecurityResult = astRoot
1694| ? parseForSecurityFromAst(input.command, astRoot)
1695| : { kind: 'parse-unavailable' }
1696| let astSubcommands: string[] | null = null
1697| let astRedirects: Redirect[] | undefined
1698| let astCommands: SimpleCommand[] | undefined
1699| let shadowLegacySubs: string[] | undefined
1700|
1701| // Shadow-test tree-sitter: record its verdict, then force parse-unavailable
1702| // so the legacy path stays authoritative. parseCommand stays gated on
1703| // TREE_SITTER_BASH (not SHADOW) so legacy internals remain pure regex.
1704| // One event per bash call captures both divergence AND unavailability
1705| // reasons; module-load failures are separately covered by the
1706| // session-scoped tengu_tree_sitter_load event.
1707| if (feature('TREE_SITTER_BASH_SHADOW')) {
1708| const available = astResult.kind !== 'parse-unavailable'
1709| let tooComplex = false
1710| let semanticFail = false
1711| let subsDiffer = false
1712| if (available) {
1713| tooComplex = astResult.kind === 'too-complex'
1714| semanticFail =
1715| astResult.kind === 'simple' && !checkSemantics(astResult.commands).ok
1716| const tsSubs =
1717| astResult.kind === 'simple'
1718| ? astResult.commands.map(c => c.text)
1719| : undefined
1720| const legacySubs = splitCommand(input.command)
源码引用: src/tools/BashTool/bashPermissions.ts · 第 1–80 行(共 2622 行)
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|
只读验证与沙箱
readOnlyValidation.ts 的 checkReadOnlyConstraints 分析 compound 命令:禁止 redirect 写文件、禁止 destructive 命令、cd 出项目根等。
shouldUseSandbox.ts 结合 env(CLAUDE_CODE_BASH_SANDBOX)与命令特征决定是否沙箱执行。沙箱成功时 permission 可 auto-allow,减少弹窗。
modeValidation.ts 处理 plan 模式下 Bash 限制(只读探索 vs 需 exit plan)。
工程练习: 对比 isReadOnly 与 bashToolHasPermission 的 allow 条件——前者决定并发,后者决定能否执行。
源码引用: src/tools/BashTool/readOnlyValidation.ts · 第 1–60 行(共 1991 行)
1| import type { z } from 'zod/v4'
2| import { getOriginalCwd } from '../../bootstrap/state.js'
3| import {
4| extractOutputRedirections,
5| splitCommand_DEPRECATED,
6| } from '../../utils/bash/commands.js'
7| import { tryParseShellCommand } from '../../utils/bash/shellQuote.js'
8| import { getCwd } from '../../utils/cwd.js'
9| import { isCurrentDirectoryBareGitRepo } from '../../utils/git.js'
10| import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'
11| import { getPlatform } from '../../utils/platform.js'
12| import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
13| import {
14| containsVulnerableUncPath,
15| DOCKER_READ_ONLY_COMMANDS,
16| EXTERNAL_READONLY_COMMANDS,
17| type FlagArgType,
18| GH_READ_ONLY_COMMANDS,
19| GIT_READ_ONLY_COMMANDS,
20| PYRIGHT_READ_ONLY_COMMANDS,
21| RIPGREP_READ_ONLY_COMMANDS,
22| validateFlags,
23| } from '../../utils/shell/readOnlyCommandValidation.js'
24| import type { BashTool } from './BashTool.js'
25| import { isNormalizedGitCommand } from './bashPermissions.js'
26| import { bashCommandIsSafe_DEPRECATED } from './bashSecurity.js'
27| import {
28| COMMAND_OPERATION_TYPE,
29| PATH_EXTRACTORS,
30| type PathCommand,
31| } from './pathValidation.js'
32| import { sedCommandIsAllowedByAllowlist } from './sedValidation.js'
33|
34| // Unified command validation configuration system
35| type CommandConfig = {
36| // A Record mapping from the command (e.g. `xargs` or `git diff`) to its safe flags and the values they accept
37| safeFlags: Record<string, FlagArgType>
38| // An optional regex that is used for additional validation beyond flag parsing
39| regex?: RegExp
40| // An optional callback for additional custom validation logic. Returns true if the command is dangerous,
41| // false if it appears to be safe. Meant to be used in conjunction with the safeFlags-based validation.
42| additionalCommandIsDangerousCallback?: (
43| rawCommand: string,
44| args: string[],
45| ) => boolean
46| // When false, the tool does NOT respect POSIX `--` end-of-options.
47| // validateFlags will continue checking flags after `--` instead of breaking.
48| // Default: true (most tools respect `--`).
49| respectsDoubleDash?: boolean
50| }
51|
52| // Shared safe flags for fd and fdfind (Debian/Ubuntu package name)
53| // SECURITY: -x/--exec and -X/--exec-batch are deliberately excluded —
54| // they execute arbitrary commands for each search result.
55| const FD_SAFE_FLAGS: Record<string, FlagArgType> = {
56| '-h': 'none',
57| '--help': 'none',
58| '-V': 'none',
59| '--version': 'none',
60| '-H': 'none',
源码引用: src/tools/BashTool/shouldUseSandbox.ts · 第 1–50 行(共 154 行)
1| import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
2| import { splitCommand_DEPRECATED } from '../../utils/bash/commands.js'
3| import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
4| import { getSettings_DEPRECATED } from '../../utils/settings/settings.js'
5| import {
6| BINARY_HIJACK_VARS,
7| bashPermissionRule,
8| matchWildcardPattern,
9| stripAllLeadingEnvVars,
10| stripSafeWrappers,
11| } from './bashPermissions.js'
12|
13| type SandboxInput = {
14| command?: string
15| dangerouslyDisableSandbox?: boolean
16| }
17|
18| // NOTE: excludedCommands is a user-facing convenience feature, not a security boundary.
19| // It is not a security bug to be able to bypass excludedCommands — the sandbox permission
20| // system (which prompts users) is the actual security control.
21| function containsExcludedCommand(command: string): boolean {
22| // Check dynamic config for disabled commands and substrings (only for ants)
23| if (process.env.USER_TYPE === 'ant') {
24| const disabledCommands = getFeatureValue_CACHED_MAY_BE_STALE<{
25| commands: string[]
26| substrings: string[]
27| }>('tengu_sandbox_disabled_commands', { commands: [], substrings: [] })
28|
29| // Check if command contains any disabled substrings
30| for (const substring of disabledCommands.substrings) {
31| if (command.includes(substring)) {
32| return true
33| }
34| }
35|
36| // Check if command starts with any disabled commands
37| try {
38| const commandParts = splitCommand_DEPRECATED(command)
39| for (const part of commandParts) {
40| const baseCommand = part.trim().split(' ')[0]
41| if (baseCommand && disabledCommands.commands.includes(baseCommand)) {
42| return true
43| }
44| }
45| } catch {
46| // If we can't parse the command (e.g., malformed bash syntax),
47| // treat it as not excluded to allow other validation checks to handle it
48| // This prevents crashes when rendering tool use messages
49| }
50| }
与 StreamingToolExecutor 的 Bash 级联
StreamingToolExecutor 对 Bash 有特殊错误传播:当某 Bash tool_result is_error=true,设置 hasErrored 并 abort siblingAbortController('sibling_error')。并行 Bash 收到 synthetic cancel message。
Read/WebFetch 等独立工具错误不级联——注释说明 mkdir 失败不应取消并行 Read。
Bash call 内 abortController 是 siblingAbortController 的子 controller;权限拒绝 abort 需 bubble 到 query controller(#21056 ExitPlanMode 回归)。
理解 Bash 调试时,同时打开 StreamingToolExecutor.executeTool 与 BashTool.call。
源码引用: src/services/tools/StreamingToolExecutor.ts · 第 354–364 行(共 531 行)
354| if (isErrorResult) {
355| thisToolErrored = true
356| // Only Bash errors cancel siblings. Bash commands often have implicit
357| // dependency chains (e.g. mkdir fails → subsequent commands pointless).
358| // Read/WebFetch/etc are independent — one failure shouldn't nuke the rest.
359| if (tool.block.name === BASH_TOOL_NAME) {
360| this.hasErrored = true
361| this.erroredToolDescription = this.getToolDescription(tool)
362| this.siblingAbortController.abort('sibling_error')
363| }
364| }
源码引用: src/services/tools/StreamingToolExecutor.ts · 第 294–318 行(共 531 行)
294| // Per-tool child controller. Lets siblingAbortController kill running
295| // subprocesses (Bash spawns listen to this signal) when a Bash error
296| // cascades. Permission-dialog rejection also aborts this controller
297| // (PermissionContext.ts cancelAndAbort) — that abort must bubble up to
298| // the query controller so the query loop's post-tool abort check ends
299| // the turn. Without bubble-up, ExitPlanMode "clear context + auto"
300| // sends REJECT_MESSAGE to the model instead of aborting (#21056 regression).
301| const toolAbortController = createChildAbortController(
302| this.siblingAbortController,
303| )
304| toolAbortController.signal.addEventListener(
305| 'abort',
306| () => {
307| if (
308| toolAbortController.signal.reason !== 'sibling_error' &&
309| !this.toolUseContext.abortController.signal.aborted &&
310| !this.discarded
311| ) {
312| this.toolUseContext.abortController.abort(
313| toolAbortController.signal.reason,
314| )
315| }
316| },
317| { once: true },
318| )
源码目录
点击 bashPermissions.ts、UI.tsx 等关联文件。Bash 权限 AST 依赖 utils/bash/ast.ts,跨模块阅读时一并打开。
动手练习
- 构造纯 grep 管道与 grep|tee 写文件管道,对比 isReadOnly 结果
- 在 REPL 并行触发两个只读 Bash,观察 StreamingToolExecutor 是否同时 executing
- 故意让第一个 Bash 失败,确认 sibling 收到 Cancelled: parallel tool call 消息
- 阅读 sedEditParser,找出 sed -i 如何转为 _simulatedSedEdit
- 对照 toAutoClassifierInput 返回的 command 与 buildYoloRejectionMessage 在 deny 时的模型续写行为
本章小结与延伸
BashTool = 执行 + 安全 + 展示三合一。权限细节延伸 utils/permissions/bashClassifier;执行器延伸 streaming-executor。 继续学习: