Bash tool:不是一条长寿 shell,而是每次新进程 + snapshot 注入 + cwd 回写
在日常使用 Claude Code 时,最容易产生的错觉是:Claude 连着一条“活着”的 shell 会话,我们随手敲下的 export 或 cd 会自然地延续到下一条命令。然而,在 2.1.88 源码中,这个幻觉会被冷酷的实现逻辑拆穿。
从定义开始
Bash tool 的本质是一个受限的非交互式 Shell 代理(Restricted Shell Proxy)。
它在模型眼里是一个能执行命令并返回结果的函数,但在运行时层面,它并不是常驻进程。Claude Code 采用了一种“即插即用、状态外挂”的模型:每次执行命令都会新建一个子进程,执行完立即销毁。为了让用户感觉不到这种断裂感,它通过预加载环境快照(Snapshot)和显式回写工作目录(CWD)来模拟出一种“连贯”的假象。
实现细节
这套模拟机制的核心在于三段明确的运行时操作,分别负责“启动前注入”、“执行中审计”和“结束后固化”。
1. 启动前的环境重构
在 claude-code-opensource/src/utils/Shell.ts 中,exec(...) 方法每次都会调用 spawn(...) 开启一个新的 shell 进程。这意味着上一条命令里的进程内变量(如临时 export)在物理上已经随进程销毁而消失了。
为了弥补这一点,claude-code-opensource/src/utils/shell/bashProvider.ts 在真正的用户命令执行前,会悄悄拼装一段复杂的预处理命令:
- Source Shell Snapshot:调用
claude-code-opensource/src/utils/bash/ShellSnapshot.ts。Claude Code 会在会话初期提取用户 shell 配置(如.zshrc)中安全的部分,存为快照。每次启动新 shell 都会先加载这份快照,这正是为什么你的alias和自定义函数看起来还能用的原因。 - 注入 Session Environment:加载
CLAUDE_ENV_FILE和各种 hook 文件(如sessionstart-hook-*.sh)。
2. 执行中的语义审计
Bash 命令不是盲目执行的。在命令被推入 spawn 之前,claude-code-opensource/src/tools/BashTool/commandSemantics.ts 会进行静态分析:
- 意图判定:它是只读的(
ls,grep)还是有副作用的(rm,npm install)?这直接决定了权限流(Ask/Allow/Deny)的触发。 - 禁令拦截:通过
claude-code-opensource/src/tools/BashTool/bashSecurity.ts与bashPermissions.ts对命令进行 AST 解析和安全审计,识别高危操作(如sudo、管道中的破坏性命令)。
3. 结束后的状态回写
工作目录(CWD)的“延续”是最精巧的骗局。bashProvider.ts 会在用户命令尾部强行追加 pwd -P >| <tmpfile>。 命令结束后,Shell.ts 会读取这个临时文件。如果路径发生了变化,Claude 会话会调用 setCwd(...) 更新全局状态。当下一条命令启动时,这个新路径将作为新进程的 cwd 参数传入 spawn。所以,持久化的是 Claude 的主进程状态,而非那个早已死掉的子进程。
实战注意事项
- 环境变量不天然持久化:你在 Bash tool 里手动
export FOO=bar,下一条命令里$FOO依然是空的。要永久改变环境,必须通过CLAUDE_ENV_FILE或者修改 hook 脚本。 - 别名依赖快照:虽然
alias看起来有效,但那是因为 Claude 启动时做了快照。如果你在会话进行中定义了一个新 alias,它不会进入后续命令的快照。 - 交互式程序的绝地:Bash tool 运行在非交互模式下。如果你尝试运行
vim或需要实时输入确认的脚本,进程通常会因为读不到 stdin 而超时或崩溃。Claude 会被引导改用非交互式参数。 - 强制重置机制:如果开启了
CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR=1,运行时会在每条命令结束后强制将目录拉回项目根部,阻止 Claude 随意“乱跑”。
接下来看什么
- 如果你想了解哪些命令被禁止,请看 Bash 审计规则:语义分析与黑名单。
- 如果你关心后台长驻进程的管理,请看 后台进程与生命周期管理。
- 如果你想深入了解权限拦截逻辑,请看 权限与沙箱:双层防御体系。
源码锚点
claude-code-opensource/src/utils/Shell.ts: 定义了底层的spawn逻辑与进程管理。
📄 src/utils/Shell.ts — 定义了底层的 `spawn` 逻辑与进程管理。
// If already aborted, don't spawn the process at all
if (abortSignal.aborted) {
return createAbortedCommand()
}claude-code-opensource/src/utils/shell/bashProvider.ts: 核心工厂类,负责拼装 Snapshot 加载与pwd回写逻辑。
📄 src/utils/shell/bashProvider.ts — 核心工厂类,负责拼装 Snapshot 加载与 `pwd` 回写逻辑。
// shellCwdFilePath: POSIX path used inside the bash command (pwd -P >| ...)
// cwdFilePath: native OS path used by Node.js for readFileSync/unlinkSync
// On non-Windows these are identical; on Windows, Git Bash needs POSIX paths
// but Node.js needs native Windows paths for file operations.
const shellCwdFilePath = opts.useSandbox
? posixJoin(opts.sandboxTmpDir!, `cwd-${opts.id}`)claude-code-opensource/src/utils/bash/ShellSnapshot.ts: 负责从用户环境中“偷”出 alias 和函数并持久化为快照。
📄 src/utils/bash/ShellSnapshot.ts — 负责从用户环境中“偷”出 alias 和函数并持久化为快照。
const LITERAL_BACKSLASH = '\\'
const SNAPSHOT_CREATION_TIMEOUT = 10000 // 10 seconds
/**
* Creates a shell function that invokes `binaryPath` with a specific argv[0].
* This uses the bun-internal ARGV0 dispatch trick: the bun binary checks its
* argv[0] and runs the embedded tool (rg, bfs, ugrep) that matches.
*
* @param prependArgs - Arguments to inject before the user's args (e.g.,
* default flags). Injected literally; each element must be a valid shell
* word (no spaces/special chars).
*/
function createArgv0ShellFunction(
funcName: string,
argv0: string,
binaryPath: string,
prependArgs: string[] = [],
): string {
const quotedPath = quote([binaryPath])
const argSuffix =
prependArgs.length > 0 ? `${prependArgs.join(' ')} "$@"` : '"$@"'
return [
`function ${funcName} {`,
' if [[ -n $ZSH_VERSION ]]; then',
` ARGV0=${argv0} ${quotedPath} ${argSuffix}`,
' elif [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "win32" ]]; then',
// On Windows (git bash), exec -a does not work, so use ARGV0 env var instead
// The bun binary reads from ARGV0 natively to set argv[0]
` ARGV0=${argv0} ${quotedPath} ${argSuffix}`,
' elif [[ $BASHPID != $$ ]]; then',claude-code-opensource/src/tools/BashTool/commandSemantics.ts: 负责对命令进行语义分类(读/写/高危)。
📄 src/tools/BashTool/commandSemantics.ts — 负责对命令进行语义分类(读/写/高危)。
export type CommandSemantic = (
exitCode: number,
stdout: string,
stderr: string,
) => {
isError: boolean
message?: string
}claude-code-opensource/src/tools/BashTool/utils.ts: 实现CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR等强制路径策略。
📄 src/tools/BashTool/utils.ts — 实现 `CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR` 等强制路径策略。
export function stripEmptyLines(content: string): string {
const lines = content.split('\n')
// Find the first non-empty line
let startIndex = 0
while (startIndex < lines.length && lines[startIndex]?.trim() === '') {
startIndex++
}
// Find the last non-empty line
let endIndex = lines.length - 1
while (endIndex >= 0 && lines[endIndex]?.trim() === '') {
endIndex--
}
// If all lines are empty, return empty string
if (startIndex > endIndex) {
return ''
}
// Return the slice with non-empty lines
return lines.slice(startIndex, endIndex + 1).join('\n')
}claude-code-opensource/src/utils/sessionEnvironment.ts: 管理跨命令延续的全局环境变量脚本。
📄 src/utils/sessionEnvironment.ts — 管理跨命令延续的全局环境变量脚本。
let sessionEnvScript: string | null | undefined = undefined
export async function getSessionEnvDirPath(): Promise<string> {
const sessionEnvDir = join(
getClaudeConfigHomeDir(),
'session-env',
getSessionId(),
)
await mkdir(sessionEnvDir, { recursive: true })
return sessionEnvDir
}