Claude Code 源码分析第 17 课 · 第 02

Bash 工具

Shell 执行、环境快照、命令构造、23 个安全验证器、权限管道、沙箱与后台任务。

1. 架构概述

Bash 工具是 Claude Code 触达操作系统的主要入口,也是风险最高的一类工具。一次看似简单的命令执行,背后要经过输入校验、权限判定、命令重写、快照恢复、沙箱包装、输出裁剪和结果解释等多层处理。

Bash 的难点不只是“能不能跑命令”,而是“怎样在保留用户 Shell 体验的前提下,把副作用控制在可审计、可中断、可回滚的范围内”。这一课讲的就是这套控制链条。

flowchart TD A["Claude emits Bash(command)"] --> B["validateInput\n(sleep-pattern guard)"] B --> C["checkPermissions\nbashToolHasPermission()"] C -->|allow| D["call()"] C -->|ask| E["Permission dialog"] C -->|deny| F["Blocked"] E -->|approved| D D --> G["runShellCommand generator"] G --> H["buildExecCommand\n(snapshot + eval wrap)"] H --> I["shouldUseSandbox?\nSandboxManager.wrap"] I --> J["spawn shell process\ndetached=true"] J --> K["async output streaming\nonProgress callbacks"] K --> L["interpretCommandResult\n(semantic exit codes)"] L --> M["output persistence\n(>30KB → disk)"] M --> N["mapToolResultToToolResultBlockParam"] style A fill:#1a1a2e,stroke:#e94560,color:#e94560 style F fill:#2a0d0d,stroke:#f87171,color:#f87171 style J fill:#071a10,stroke:#34d399,color:#34d399

五个重要的源目录:

tools/BashTool/

顶级工具定义、权限调度、sed-edit shim、UI 分类、输出整形

tools/BashTool/bash*.ts

bashSecurity.ts — 23 个验证器; bashPermissions.ts — 规则匹配 + 环境变量剥离

utils/bash/

AST 解析、命令分割、管道重新排列、heredoc 处理、ShellSnapshot

utils/shell/

BashProvider — 构建 exec 命令字符串; 输出限制; 外壳检测; powershell路径

utils/sandbox/

SandboxManager — 在进程生成时执行文件系统 + 网络策略

2. 输入/输出架构

Bash 工具对模型暴露的是一个“受限制的公共输入模式”,对内部执行路径则保留一套更完整的输入结构。最典型的例子就是 _simulatedSedEdit:这个字段内部会用,但绝不允许模型直接提供。

这样做的原因很直接。如果模型能自己构造这个隐藏字段,就可能把真实写文件动作伪装成一条看起来无害的命令,通过权限弹窗欺骗用户。

// tools/BashTool/BashTool.tsx — fullInputSchema (internal)
const fullInputSchema = lazySchema(() => z.strictObject({
  command:                 z.string(),          // the shell command
  timeout:                 z.number().optional(), // ms, max = getMaxTimeoutMs()
  description:             z.string().optional(), // model-facing active-voice summary
  run_in_background:       z.boolean().optional(),
  dangerouslyDisableSandbox: z.boolean().optional(),

  // NEVER exposed to model — set only by sed-edit permission dialog
  _simulatedSedEdit: z.object({
    filePath: z.string(),
    newContent: z.string()
  }).optional()
}))

// Public schema omits _simulatedSedEdit (and optionally run_in_background)
const inputSchema = lazySchema(() =>
  isBackgroundTasksDisabled
    ? fullInputSchema().omit({ run_in_background: true, _simulatedSedEdit: true })
    : fullInputSchema().omit({ _simulatedSedEdit: true })
)
为什么隐藏_simulatedSedEdit?

如果模型可以设置 _simulatedSedEdit,它可以写入任意文件内容,同时将其与看起来无害的命令配对,例如 echo done。 权限对话框将显示回显,而不是文件写入。 从模式中隐藏字段使得这在结构上是不可能的。

输出模式

// tools/BashTool/BashTool.tsx — outputSchema
z.object({
  stdout:           z.string(),
  stderr:           z.string(),
  interrupted:      z.boolean(),
  rawOutputPath:    z.string().optional(),     // MCP large-output path
  isImage:          z.boolean().optional(),    // base64 PNG/JPEG detected
  backgroundTaskId: z.string().optional(),     // set when run_in_background=true
  backgroundedByUser:        z.boolean().optional(),
  assistantAutoBackgrounded: z.boolean().optional(), // auto-bg after 15s
  dangerouslyDisableSandbox: z.boolean().optional(),
  returnCodeInterpretation:  z.string().optional(),  // semantic exit-code note
  noOutputExpected:          z.boolean().optional(),  // shows "Done" vs "(No output)"
  persistedOutputPath: z.string().optional(),  // >30KB → written to disk
  persistedOutputSize: z.number().optional()
})
stderr 合并到 stdout

shell 提供者使用 2>&1 — stderr 在文件描述符级别合并到 stdout。 这 stderr 因此,输出模式中的字段是 总是空的 为正常执行路径。 期望 stderr 携带错误文本的调用者将会感到惊讶。 只有 shell-reset 附加路径写入 stderr 直接领域。

3.Shell快照系统

Bash 工具之所以能“像用户自己的 shell 一样工作”,靠的不是每次都启动一个昂贵的交互式登录 shell,而是会话开始时先做一次环境快照,然后后续命令都复用这份快照。

这份快照保存的不是简单 PATH,而是一整套 shell 语境:别名、函数、选项、某些 shim,以及配置文件加载后的状态。这让 Claude 既能拿到贴近用户环境的执行结果,又不至于每条命令都付出完整登录 shell 的成本。

flowchart LR A["Session start"] --> B["createAndSaveSnapshot(shellPath)"] B --> C["getSnapshotScript()"] C --> D["source .zshrc / .bashrc\n< /dev/null"] D --> E["Capture:\n• Functions (typeset -f)\n• Shell options (setopt)\n• Aliases (alias --)\n• PATH (process.env.PATH)\n• rg / find / grep shims"] E --> F["Write snapshot-{ts}-{rand}.sh"] F --> G["buildExecCommand()\nsource snapshot || true"] style A fill:#1a1a2e,stroke:#7ec8e3,color:#7ec8e3 style F fill:#071a10,stroke:#34d399,color:#34d399
快照中到底包含了什么?

快照脚本使用以下命令获取用户的配置文件 < /dev/null (因此不会触发依赖于 TTY 的提示),然后附加:

  1. 一切都取消别名unalias -a 2>/dev/null || true — 避免在函数定义中使用别名“freeze”。
  2. 用户功能typeset -f (zsh) 或 declare -F | base64-encode each function (重击)。 以单下划线开头的补全函数会被过滤掉; 双下划线助手(mise、pyenv)被保留。
  3. 外壳选项setopt 行 (zsh) 或 shopt -p + set -o on | awk 行(bash)。 然后强制启用 expand_aliases.
  4. Aliasesalias -- 成型、剥离 winpty Windows 上的别名。
  5. RG垫片 — 如果系统 rg 不存在,则使用 ARGV0 调度技巧注入由 Bun 的嵌入式 ripgrep 支持的 shell 函数。
  6. 查找/grep 垫片 - 在 ant-native 构建中,始终隐藏系统 find/grep 带嵌入式 bfs/ugrep.
  7. 路径导出export PATH=... from process.env.PATH.
// utils/bash/ShellSnapshot.ts — snapshot creation (simplified)
export const createAndSaveSnapshot = async (binShell: string) => {
  const configFile = getConfigFile(binShell)       // .zshrc / .bashrc / .profile
  const configFileExists = await pathExists(configFile)

  const snapshotPath = join(
    getClaudeConfigHomeDir(), 'shell-snapshots',
    `snapshot-${shellType}-${Date.now()}-${randomId}.sh`
  )

  const script = await getSnapshotScript(binShell, snapshotPath, configFileExists)
  execFile(binShell, ['-i', '-c', script], { timeout: 10000 })
  return snapshotPath
}

Shell 快照并不是只记录一个 PATH。它会尽可能恢复用户实际交互 shell 里的上下文:别名、函数、部分 shell 选项,以及补充的工具 shim。

这样做的目的,是让 Claude 运行命令时尽量贴近用户平时在终端里的体验;否则模型看到的将是一个过于“干净”的 shell,和真实开发环境差距很大。

如果快照创建失败会发生什么情况?

承诺被包裹在 .catch() 并决心 undefined。 在 buildExecCommand, 如果 snapshotFilePath 未定义,该命令由 -l (登录 shell 标志),因此它仍然从用户的配置文件进行初始化。 会话会正常降级 - 没有别名或自定义函数,但命令仍然执行。

还有一个 TOCTOU 感知的重新检查:在每个命令之前, access(snapshotFilePath) 验证该文件仍然存在。 如果操作系统已清理 /tmp 会议中期, lastSnapshotFilePath 被清除并重新启用登录 shell 回退。

快照失败不会把整条 Bash 路径打断。Claude Code 会优雅回退到登录 shell 初始化方式继续执行命令,只是少了快照里恢复的别名和函数。

这说明快照是优化项,不是致命依赖。系统宁可退化,也不愿因为环境恢复失败而让整个命令执行链失效。

4.指挥建设管道

buildExecCommand() 真正做的不是“把一行命令拼起来”,而是在安全与可用性之间做平衡:修正 Windows 空设备重定向、决定是否补 < /dev/null、加上 eval 包装、处理管道位置,再把快照与环境脚本一起接入最终命令串。

这里的复杂度几乎都来自 shell 语义本身。Claude Code 不是简单调用 spawn(command),而是在努力让命令既保留用户习惯、又不会被 shell 细节和注入边角情况轻易绕过。

flowchart TD A["raw command string\n(from Claude)"] --> B["rewriteWindowsNullRedirect()\n2>nul → 2>/dev/null"] B --> C["shouldAddStdinRedirect()?\nappend < /dev/null"] C --> D["quoteShellCommand()\nwrap in single quotes for eval"] D --> E{"contains | pipe\nAND needs stdin redirect?"} E -->|yes| F["rearrangePipeCommand()\nmove < /dev/null to first segment"] E -->|no| G F --> G["Assemble command parts:\n1. source snapshot || true\n2. source sessionEnvScript\n3. disable extglob\n4. eval 'quoted_cmd'\n5. pwd -P >| cwd-file"] G --> H{"CLAUDE_CODE_SHELL_PREFIX\nset?"} H -->|yes| I["formatShellPrefixCommand()\nwrap entire string"] H -->|no| J["Final commandString"] I --> J style A fill:#1a1a2e,stroke:#e94560,color:#e94560 style J fill:#071a10,stroke:#34d399,color:#34d399
为什么 eval 需要单引号——为什么管道重新排列很重要?

评估引用: 快照源自与用户命令相同的 shell 调用。 Bash 在执行之前解析整行,因此快照中的别名在解析时尚不可用——它们只有在之后才可用 source snapshot 运行。 使用 eval 'command' 创建一个 第二次解析过程 快照别名现已生效。 功能 singleQuoteForEval() 将命令括在单引号中,将内部单引号转义为 '"'"' (not \',这会打破 jq/awk 过滤器含有 !=).

管道重新布置: 无需特殊处理, eval 'rg foo | wc -l' < /dev/null causes wc 从中读取 /dev/null (输出0)同时 rg 永远等待继承的 stdin 管道。 修复:注入 < /dev/null between 第一个命令和管道,因此只有第一个命令获得空标准输入。 解析器在中处理这个问题 rearrangePipeCommand().

// Before: wc reads /dev/null, rg blocks
eval 'rg foo | wc -l' < /dev/null

// After: rg reads /dev/null, wc reads rg's output
eval 'rg foo' < /dev/null | wc -l

管道重新排列可以避免(退回到整个命令引用):

  • 带反引号的命令或 $() — shell-quote 错误地解析了它们
  • 带有 shell 变量的命令 ($VAR) — shell 引用会丢弃它们
  • 控制结构(for/while/if/case) — 找不到管道边界
  • Shell 引用单引号 bug 模式 ('\' payload '\')
  • 带有裸换行符的命令 — shell 引用将其视为空格

单引号 eval 包装的根本原因,是让快照里恢复出来的别名和函数参与第二次 shell 解析。否则命令在第一次解析时就已经定型,后面再 source snapshot 也来不及影响它。

而管道重排则是在修一个非常具体的 shell 陷阱:如果整条命令都挂上 < /dev/null,可能会让错误的那一段去消费 stdin,导致前半段阻塞、后半段读空输入。Claude Code 在这里是在替模型兜底 shell 细节。

extglob 禁用如何工作以及为什么?

获取快照后(可能会从用户设置重新启用 extglob),提供程序会注入一个命令来禁用扩展 glob 模式:

// bash
shopt -u extglob 2>/dev/null || true

// zsh
setopt NO_EXTENDED_GLOB 2>/dev/null || true

// When CLAUDE_CODE_SHELL_PREFIX is set (shell may differ)
{ shopt -u extglob || setopt NO_EXTENDED_GLOB; } >/dev/null 2>&1 || true

扩展的全局变量 (!(pattern), @(a|b)等)可以由我们的安全验证完成后创建的文件名触发。 一个名为 !(safe_file) 工作目录中的内容可以匹配并扩展为一个命中任意路径的全局变量。 禁用 extglob 后快照可以防止此类验证后扩展攻击。

拿到 shell 快照后,Claude Code 会显式关闭 extglob 一类扩展 glob 功能。原因不是兼容性,而是安全性:某些文件名和模式在 extglob 打开时会被解释成更危险的匹配。

换句话说,这一步是在防止“验证通过后的命令”因为 shell 额外展开而变成另一个东西。它防的是验证后扩展,而不是验证前输入本身。

5. 23个安全验证器

Bash 安全验证并不是一个“大 if”或单条黑名单规则,而是一串分层验证器。每个验证器只负责拦一类风险:不完整命令、危险变量、控制字符、命令替换、重定向、here-doc、jq 特殊函数等等。

这种链式设计的好处是:每条规则都可以非常聚焦,而且第一条明确命中的结论就能直接返回。它形成的是纵深防御,而不是依赖某一个万能检查。

allow

Early-allow:命令是安全的,跳过所有剩余的验证器

passthrough

没有意见——继续链中的下一个验证者

ask

触发权限对话框——该命令可能是危险的

验证器按顺序运行。 第一个非passthrough 结果获胜。

#1
INCOMPLETE_COMMANDS
以制表符开头,前导标志 (-rf),或延续运算符 (&&, ;)。 捕获仅在某些内容的后半部分才有意义的命令片段。
#2
JQ_SYSTEM_FUNCTION
Detects jq with env, path, builtins, modulemeta, debug — jq 内在函数可能会泄漏运行时环境或通过 jq 模块触发 RCE。
#3
JQ_FILE_ARGUMENTS
Detects jq --args, --jsonargs, --rawfile, --slurpfile, 或者 --arg 可以读取任意文件或注入 shell 参数的模式。
#4
OBFUSCATED_FLAGS
带有编码字符或异常间距的标志,这些字符在验证器看到的内容和 shell 执行的内容之间会有所不同。
#5
SHELL_METACHARACTERS
Unquoted ;, &&, ||, | — 复合命令。 这是主要的多命令连接检查。 最有趣的权限提示来自这里。
#6
DANGEROUS_VARIABLES
参考文献 $BASH_ENV, $ENV, $CDPATH, $IFS — 影响全局 shell 行为并可用于重新路由执行的 shell 变量。
#7
NEWLINES
命令字符串中不带引号的换行符。 换行符是一个命令分隔符,等同于 ; 在 bash 中,使其成为绕过 naive 的常见注入向量 ;- 仅检查。
#8
DANGEROUS_PATTERNS — 命令替换
Unquoted $(), ${}, $[]、反引号、过程替换 <() / >(), 兹什 =() 扩展、PowerShell <# comments.
#9
DANGEROUS_PATTERNS — 输入重定向
Unquoted < (以外 < /dev/null 这是安全的)。 从任意文件或进程替换中读取。
#10
DANGEROUS_PATTERNS — 输出重定向
Unquoted > or >> 到非空、非 fd 目标。 写入文件需要通过以下方式进行单独的权限检查 checkPathConstraints.
#11
IFS_INJECTION
Assignment IFS= 或未引用的 $IFS。 更改内部字段分隔符是一种经典的令牌分割攻击,它重写了后续分词的工作方式。
#12
GIT_COMMIT_SUBSTITUTION
一个特殊的早期允许验证器 git commit -m "msg"。 允许常见模式,但如果消息包含 $()、反引号,或者余数有 shell 运算符。
#13
PROC_ENVIRON_ACCESS
参考文献 /proc/*/environ or /proc/self/environ。 该文件包含任何正在运行的进程的完整环境,包括机密。
#14
MALFORMED_TOKEN_INJECTION
Uses hasMalformedTokens() 来自 shell-quote 解析器。 检测类似的命令 echo {"hi":\"hi;calc.exe"} 其中 shell-quote 将不平衡的引号错误标记为有效的 bash 注入。
#15
BACKSLASH_ESCAPED_WHITESPACE
反斜杠转义空格或制表符用于将空格偷偷放入标记中,从而改变分词的工作方式。 tr\ace 就像一个词看起来一样 trase 但执行为 traceroute- 等效的代币链。
#16
BRACE_EXPANSION
不带引号的大括号扩展 {a,b,c} or {1..10}。 大括号扩展发生在通配符之前,并且可以生成静态分析不可见的参数列表。
#17
CONTROL_CHARACTERS
命令字符串中的原始 ASCII 控制字符(\x00–\x1f,制表符/换行符除外)。 这些在 UI 中是不可见的,并且可能会混淆终端解析或注入日志文件。
#18
UNICODE_WHITESPACE
bash 将非 ASCII 空白(NBSP \u00A0、em 空格、零宽度空格等)视为单词边界,但在 UI 中看起来什么也没有。 经典的隐形注射矢量。
#19
MID_WORD_HASH
A # 出现在结束报价旁边,例如 'x'#。 如果没有此检查,引用的内容可能会被删除,留下 # 在单词开头,bash 将其视为注释,隐藏其后的所有内容。
#20
ZSH_DANGEROUS_COMMANDS
基本命令: zmodload, emulate, sysopen, sysread, syswrite, zpty, ztcp, zsocket,加上 Zsh 文件内置函数(zf_rm, zf_mv等)绕过二进制命令拒绝列表。
#21
BACKSLASH_ESCAPED_OPERATORS
shell 操作符前的反斜杠如 \;, \|, \& - 有时用于偷偷通过简单的基于字符串的检测,同时在某些情况下对 shell 仍然有意义。
#22
COMMENT_QUOTE_DESYNC
引号剥离放置 a 的命令 # 在字边界处,创建隐藏 shell 运算符的注释。 例子: echo 'x'#dangerous_suffix.
#23
QUOTED_NEWLINE
带引号的字符串内的文字换行符。 尽管在 bash 中语法上有效,但这是一种不寻常的模式,可以指示在引用的参数内组装注入的多行有效负载。
安全的heredoc早期允许路径

在主验证者链之前,有一个特殊的早期允许模式路径 cmd $(cat <<'DELIM'\n...\nDELIM\n)。 这是将多行内容作为提交消息或脚本参数传递而不触发命令替换验证器的规范方法。

The isSafeHeredoc() 功能用途 基于行的匹配,而不是正则表达式,来查找结束分隔符——完全复制 bash 自己的定界符结束行为。 主要安全条件:

  1. 分隔符必须是单引号 (<<'EOF') 或反斜杠转义 (<<\EOF)所以正文是没有扩展的文字文本。
  2. 结束分隔符必须单独占一行。
  3. 替换必须出现在 论证立场,而不是命令名称位置(命令之前必须有非空格前缀 $().
  4. 剥离定界符后剩余的文本必须仅包含安全字符(无 shell 元字符)。
  5. 嵌套的定界符匹配被拒绝——它们表明外部定界符的文字正文包含看起来像另一个定界符的内容,我们的正则表达式无法安全地区分。
// Safe — ALLOWED:
git commit -m "$(cat <<'EOF'
Fix the login bug

Resolves #1234
EOF
)"

// Unsafe — BLOCKED (substitution in command-name position):
$(cat <<'EOF'
chmod
EOF
) 777 /etc/shadow
ValidationContext 是如何构建的

每个验证者都会收到一个 ValidationContext 有五个预先计算的命令视图:

type ValidationContext = {
  originalCommand:        string  // verbatim from Claude
  baseCommand:            string  // first token after env vars
  unquotedContent:        string  // double-quotes stripped
  fullyUnquotedContent:   string  // both quote types stripped
  fullyUnquotedPreStrip:  string  // before safe-redirection stripping
  unquotedKeepQuoteChars: string  // strips content but keeps quote delimiters
  treeSitter?:            TreeSitterAnalysis | null  // AST, if available
}

unquotedKeepQuoteChars 专门用于 validateMidWordHash:它会删除带引号的字符串的内容,但保留引号字符本身。 这揭示了类似的模式 'x'# where x 位于单引号内并且 # 是引号相邻的——在这种情况下,简单的剥离会隐藏 # entirely.

每个 Bash 安全验证器拿到的并不是同一份原始命令字符串,而是一组预先算好的视图:原始命令、基础命令、去掉部分引号后的内容、完全去引号后的内容,以及专门保留引号边界的版本。

这么做是为了让不同验证器针对不同风险面工作。例如有的关注控制字符,有的关注命令替换,有的则需要在去掉内容的同时保留引号边界,才能发现更隐蔽的模式。

安全性:这些验证器在每个子命令上运行

对于复合命令(cmd1 && cmd2), splitCommand_DEPRECATED 将它们分成单独的子命令,验证器链在每个子命令上运行。 有一个上限 50 个子命令 — 除此之外系统返回 ask 作为安全的默认设置,因为合法的用户命令几乎不会分割那么宽,并且无限制的增长会触发真正的 CPU 饥饿 DoS。

6. 管道许可

通过安全验证之后,命令还要再走一层权限判断。这里考虑的不只是“危险不危险”,还包括当前 permission mode、用户已有的 allow/deny 规则、包装器剥离后的真实命令,以及自动分类器结果。

换句话说,Bash 的权限系统不是单点确认框,而是一套基于命令形态归一化之后再做匹配的决策系统。这也是为什么它能把 NODE_ENV=prod npm run build 这类命令正确归入同一规则族。

flowchart TD A["bashToolHasPermission(input, context)"] --> B["checkPermissionMode()\nread-only mode?"] B -->|deny| Z1["deny"] B -->|pass| C["bashCommandIsSafe()\n23 validators"] C -->|ask| Z2["ask — security concern"] C -->|allow| D["checkReadOnlyConstraints()"] C -->|pass| D D -->|deny| Z3["deny — read-only violation"] D -->|pass| E["checkPathConstraints()\npath allowlist / denylist"] E -->|deny| Z4["deny — path out-of-scope"] E -->|pass| F["checkSedConstraints()\nsed -i detection"] F -->|ask| Z5["ask — sed preview dialog"] F -->|pass| G["checkCommandOperatorPermissions()\nper-subcommand rule matching"] G -->|allow| Z6["allow"] G -->|ask| Z7["ask — needs user rule"] G -->|deny| Z8["deny — explicit deny rule"] style Z6 fill:#071a10,stroke:#34d399,color:#34d399 style Z1 fill:#2a0d0d,stroke:#f87171,color:#f87171 style Z8 fill:#2a0d0d,stroke:#f87171,color:#f87171
规则匹配前剥离环境变量

像这样的规则 Bash(npm run:*) 应该匹配 NODE_ENV=production npm run build。 为了实现这一点, stripSafeWrappers() 在与规则进行比较之前执行两阶段剥离:

第一阶段: 去掉变量名所在的前导环境变量 SAFE_ENV_VARS。 允许列表涵盖无法执行代码的构建/区域设置/显示变量。 变量如 PATH, LD_PRELOAD, PYTHONPATH 故意不在列表中。

第二阶段: 剥离包装器命令: timeout (具有完整的 GNU 标志解析), time, nice (所有调用形式), nohup。 每个都使用带有允许列表字符类的正则表达式作为标志值 - 旧的 [^ \t]+ 模式被绕过 timeout -k$(id) 10 ls where $(id) 匹配为持续时间值并被剥离。

两个阶段都在定点循环中运行(重复直到稳定),处理交错模式,例如 timeout 300 NODE_ENV=prod npm run build.

// SECURITY NOTE: Phase 2 does NOT strip env vars.
// After a wrapper, VAR=val is treated as the command to execute.
// `timeout 10 MY_CMD=foo` — MY_CMD=foo is the command name, not an env assignment.
// Stripping it here would create a false permission match.

权限规则匹配前会先把某些安全的前导环境变量和包装器命令剥掉,例如 NODE_ENV=prodtimeoutnice。这样一来,规则匹配看到的是更接近“命令本体”的样子。

但这个剥离是带白名单的,不是无差别删除。像 PATHLD_PRELOAD 这种会影响执行语义的变量,故意不会被剥掉。

规则匹配:精确匹配、前缀匹配、通配符匹配

设置中存储的权限规则使用以下格式 Bash(content)。 内容有三种匹配模式:

ModeFormatExampleMatches
Exactfull commandBash(git status)Only git status,没有别的
前缀(旧版)cmd:*Bash(npm run:*)npm run + 之后的任何内容
Wildcardpattern with *Bash(git * --force)任何与 glob 匹配的命令

The getSimpleCommandPrefix() 函数也会自动生成 2字前缀规则 当用户批准命令时。 为了 git commit -m "fix typo",建议是 Bash(git commit:*),而不是文字命令(它永远不会再次匹配)。 对于heredoc命令,前缀是在 << operator.

裸外壳名称 (bash, sh, zsh, python, env, sudo等)完全排除在前缀建议之外 - Bash(bash:*) 将相当于 Bash(*) 并批准任意代码执行。

Bash 权限规则不是只有“全文匹配”一种形式。它既支持完整命令匹配,也支持前缀规则和带 * 的通配形式,这样系统才能把一次批准提升成后续可复用的规则。

比如批准一次 git commit -m ...,系统更倾向于记录成 Bash(git commit:*),因为这才是真正可复用的意图边界。

7. 沙盒

即便命令被允许执行,也不代表它会直接在完全开放的环境里跑。是否进入沙盒、沙盒开放哪些目录和网络能力,是另外一条独立决策链。

这一层和权限提示的关系很重要:权限是“你能不能做”,沙盒是“你做的时候边界在哪里”。两者配合,才能让 Claude 既可执行真实命令,又不至于失控。

function shouldUseSandbox(input: SandboxInput): boolean {
  // 1. Sandboxing must be enabled in the first place
  if (!SandboxManager.isSandboxingEnabled()) return false

  // 2. Explicit user override (AND policy must allow unsandboxed commands)
  if (input.dangerouslyDisableSandbox && SandboxManager.areUnsandboxedCommandsAllowed())
    return false

  // 3. Empty command
  if (!input.command) return false

  // 4. User-configured excluded commands (not a security boundary — just convenience)
  if (containsExcludedCommand(input.command)) return false

  return true
}

沙箱通过以下方式在进程生成时控制文件系统和网络访问 SandboxManager。 通过系统提示,Claude 会收到关于活动限制的提示,以便它可以调整其行为:

  • 文件系统读取:仅拒绝列表(例如, ~/.ssh)
  • 文件系统写入:仅允许列表(例如,项目目录 + $TMPDIR)
  • Network:可选的 allowedHosts / DeniedHosts
exceptedCommands 不是安全边界

sandbox.excludedCommands 设置中是一个方便的功能 - 它允许用户运行诸如 docker or bazel 没有沙箱,因为这些工具需要直接进程访问。 但这不是安全控制。 权限提示系统是。 源代码中的评论明确记录了这一点,以防止滥用。

如何根据排除命令检查复合命令

containsExcludedCommand() 拆分复合命令并检查每个子命令。 此外,对于每个子命令,它都会生成剥离形式的定点闭包(剥离的环境变量、剥离的包装器)并根据模式检查所有变体。 这处理 FOO=bar bazel run //... 匹配一个 bazel:* 排除的模式。

定点方法与权限规则检查中使用的剥离逻辑相匹配——如果模式在权限检查时匹配,那么它在沙箱排除时也匹配。 一致性可以防止命令通过权限检查永远不会匹配的排除模式逃离沙箱的情况。

沙箱排除检查不会只看整条命令的第一个词,而是会把复合命令拆开,逐段检查每个子命令,并结合剥离后的等价形式做匹配。

这样设计是为了避免一种错位:权限系统能识别某条命令属于某个规则,但沙箱排除却看不出来。如果两边逻辑不一致,就会出现难以解释的安全漏洞或误判。

8.后台执行

后台执行有三条路径:模型显式要求、用户手动送后台,以及助理模式下的自动后台化。它们表面效果相似,内部却会带不同标记,方便后续 UI 和模型推理区分“这是用户主动送后台的”还是“系统为了响应性自动处理的”。

这里体现的不是单纯性能优化,而是对交互体验的妥协设计:Claude Code 宁可把长任务转入后台,也不愿把主对话线程完全堵死。

run_in_background:true

显式模型发起的背景。 当模型不需要立即得到结果时,会设置该字段。

用户按下 Ctrl+B

手动背景。 用户将正在运行的命令移至后台执行中。 套 backgroundedByUser: true.

自动背景(辅助模式)

在助理模式下 15 秒后,阻塞命令会自动移至后台。 套 assistantAutoBackgrounded: true.

当命令处于后台时, backgroundTaskId 返回并输出流式传输到文件 getTaskOutputPath(taskId)。 该模型被赋予了输出路径,以便稍后可以通过 FileRead 工具读取它。

// tools/BashTool/BashTool.tsx — assistant auto-background message
const ASSISTANT_BLOCKING_BUDGET_MS = 15_000

backgroundInfo = `Command exceeded the assistant-mode blocking budget (${ASSISTANT_BLOCKING_BUDGET_MS / 1000}s) 
and was moved to the background with ID: ${backgroundTaskId}. 
Output is being written to: ${outputPath}. 
In assistant mode, delegate long-running work to a subagent or use run_in_background`
睡眠阻滞剂模式

当启用监控工具功能时,独立 sleep N (其中 N ≥ 2)作为第一个子命令被阻止 validateInput() 在进行任何权限检查之前。 错误消息解释了原因以及应该使用什么:

function detectBlockedSleepPattern(command: string): string | null {
  const m = /^sleep\s+(\d+)\s*$/.exec(first)
  if (!m) return null
  const secs = parseInt(m[1]!, 10)
  if (secs < 2) return null  // sub-2s sleeps are fine (rate limiting, pacing)

  const rest = parts.slice(1).join(' ').trim()
  return rest
    ? `sleep ${secs} followed by: ${rest}`    // suggest Monitor
    : `standalone sleep ${secs}`               // "what are you waiting for?"
}

浮动持续时间睡眠 (sleep 0.5)是豁免的——它们代表合法的速率限制或故意的节奏,而不是轮询循环。 该模式仅在整数持续时间内触发。

对长时间的 sleep N 做前置拦截,本质上是在阻止模型把 Bash 当轮询器用。真正需要等待或轮询的场景,应当交给更合适的监控工具或后台机制。

因此这条规则并不是讨厌 sleep,而是避免模型用一条最原始、最阻塞的命令占住执行槽位,影响整体交互与调度。

9. 输出处理

Bash 输出处理不只是收集 stdout。它还要决定何时截断、何时落盘、何时识别图像、何时把退出码解释成人类可读语义,以及何时剥离内部提示协议标记。

也就是说,Bash 工具真正返回给模型的并不是‘原始子进程输出’,而是一份经过解释、过滤和预算控制后的结果表示。

输出尺寸限制

// utils/shell/outputLimits.ts
export const BASH_MAX_OUTPUT_UPPER_LIMIT = 150_000  // chars — hard cap
export const BASH_MAX_OUTPUT_DEFAULT     = 30_000   // chars — configurable via BASH_MAX_OUTPUT_LENGTH

当输出超出内联限制时,完整输出将保留到工具结果目录中的文件中。 该模型接收到一个 <persisted-output> 包含文件路径和预览的消息。 工具结果目录副本的上限为 64MB 通过硬链接之前的截断。

图像检测和调整大小

If stdout 以 Base64 编码的 PNG 或 JPEG 标头开头, isImageOutput() sets isImage: true 输出被包装在 image 克劳德的内容块。 大图像在发送之前会调整大小,以保持在内容块限制之内。

语义退出代码解释

interpretCommandResult() 使用每个命令的众所周知的代码表将非零退出代码映射到人类可读的注释。 例如, grep 返回 1 表示“没有匹配项”(不是错误),并且 diff 返回 1 表示“文件不同”(不是错误)。 这些在 UI 中显示为 returnCodeInterpretation 而不是将它们视为失败。

Claude Code 提示协议

设置的 CLIs 和 SDKs CLAUDECODE=1 可以发射 <claude-code-hint /> 标记到 stderr(合并到 stdout)。 该工具扫描这些标签,记录它们以供提示推荐系统使用,然后 在模型看到输出之前剥离它们 — 零代币侧通道。 子代理输出也会被剥离,因此提示不会逃脱代理边界。

10.UI分类

Claude Code 不只是执行命令,也会给这些命令打上展示语义:哪些结果默认可以折叠、哪些属于读取类输出、哪些属于静默成功命令、哪些更像文件编辑而不是 Bash 命令本身。

这种分类直接影响用户界面和权限提示的可理解性。换句话说,UI 里的 Bash 并不是‘原样显示命令行’,而是对命令意图做了一层重新解释。

// tools/BashTool/BashTool.tsx — command sets for collapsible display
const BASH_SEARCH_COMMANDS = new Set(['find', 'grep', 'rg', 'ag', 'ack', ...])
const BASH_READ_COMMANDS   = new Set(['cat', 'head', 'tail', 'jq', 'awk', ...])
const BASH_LIST_COMMANDS   = new Set(['ls', 'tree', 'du'])

// Semantic-neutral: don't affect the read/search classification of a pipeline
const BASH_SEMANTIC_NEUTRAL_COMMANDS = new Set(['echo', 'printf', 'true', 'false', ':'])

对于像这样的管道 cat file | jq .name, 所有零件 必须是整个管道的搜索/读取命令才能被视为可折叠。 单个非搜索命令使整个管道不可折叠。 中性命令(echo, true) 在任何位置都会被跳过。

The BASH_SILENT_COMMANDS 放 (mv, cp, rm, mkdir等)驱动一个单独的决定:当这些命令成功但没有标准输出时,在 UI 中显示“完成”而不是“(无输出)”。

The sed 特殊情况:如果命令与 sed 就地编辑模式匹配(sed -i 's/.../.../' file),该工具的 userFacingName() 使用以下命令将其呈现为文件编辑操作(不是 bash 命令) FileEdit 工具的显示名称。 这意味着用户看到的是文件差异样式的权限对话框,而不是通用命令批准。

要点

本课要记住什么

  • shell 快照运行 每次会话一次 并捕获别名、函数、选项和路径。 每个命令都会获取此快照,为用户提供完整的交互环境,而无需每次都生成登录 shell。
  • The 指挥大楼管道 有六个阶段:Windows-null 重写、stdin 重定向决策、单引号 eval 包装、管道重新排列(带 stdin 修复)、快照源、extglob 禁用。 每个阶段都有特定的安全性或正确性原因。
  • The 23 个验证者 in bashSecurity.ts 防御每一层的注入攻击:字符编码(unicode 空格、控制字符)、shell 语法(大括号扩展、反引号、进程替换)、Zsh 特定的转义,甚至 jq 内部函数。 它们形成了纵深防御堆栈,而不是单一的门。
  • 安全的heredoc早期允许路径是 积极的断言 ——它会使所有验证器短路。 因此,它比任何单独的验证器具有更严格的条件:基于行的分隔符匹配、参数位置要求、无嵌套此处文档,以及剩余部分是否通过所有验证器的最终检查。
  • 规则匹配之前的环境变量剥离是 安全敏感白名单。 安全列表省略 PATH, LD_PRELOAD和模块路径变量正是因为它们影响执行,而不仅仅是行为。
  • stderr 合并到 stdout via 2>&1 在文件描述符级别。 这 stderr 对于普通命令,输出模式中的字段始终为空。 期望 stderr 携带错误内容的调用者将会感到惊讶。
  • 沙盒排除不是安全控制 — 它们是便利功能。 权限对话框是实际的安全控制。 来源评论明确记录了这种区别。
  • 后台执行具有三种路径(显式、用户启动、15 秒后自动),并在输出中带有可区分的标志。 该模型的自动背景路径仅在助手模式下触发,从而保持交互式会话的响应能力。
  • max-subcommand 上限为 50 是针对复杂复合命令中指数增长的子命令数组造成的 CPU 饥饿 DoS 的真正生产修复。

点击选项后即可查看答案解析。

知识检查

Q1. 当你批准一条 git commit -m "fix" 规则时,Claude Code 实际保存的通常是哪种规则?
正确! 权限系统不会把规则固化得过细,而是会尽量提炼成合理的前缀匹配。对 git commit 来说,常见结果就是保存成 Bash(git commit:*)
Q2. 如果 shell 快照文件在会话中途被系统清理掉,后续 Bash 命令会怎样?
正确! 实现里做了存在性复查。快照丢失后会回退到登录 shell 初始化路径,不会直接把会话打死,只是用户环境恢复得没那么完整。
Q3. 哪个验证器会拦下类似 echo $'\n'evil_command 这样的输入?
正确! 这类输入利用的是 shell 扩展与引用语义,不是普通换行本身。Claude Code 会在“危险模式 / 替换扩展”这层就把它拦下来。
Q4. 为什么 Bash 工具输出结构里的 stderr 通常是空的?
正确! 这里不是“没收集”,而是“早就合并了”。大多数正常执行路径里,stderr 会在 shell 层面并入 stdout,所以结构体里的 stderr 看起来几乎总为空。
Q5. 为什么验证器链会把复合命令的子命令数量上限卡在 50?
正确! 这不是拍脑袋的常数,而是生产事故后的防御阈值。复杂组合命令会让解析与验证阶段指数膨胀,导致事件循环在高 CPU 下饥饿。
0/5