Skip to content
源码分析手册

Bash tool:不是一条长寿 shell,而是每次新进程 + snapshot 注入 + cwd 回写

在日常使用 Claude Code 时,最容易产生的错觉是:Claude 连着一条“活着”的 shell 会话,我们随手敲下的 exportcd 会自然地延续到下一条命令。然而,在 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.tsbashPermissions.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 随意“乱跑”。

接下来看什么

源码锚点

  • claude-code-opensource/src/utils/Shell.ts: 定义了底层的 spawn 逻辑与进程管理。
📄 src/utils/Shell.ts — 定义了底层的 `spawn` 逻辑与进程管理。L240-243 of 475
typescript
  // 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` 回写逻辑。L112-117 of 256
typescript
      // 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 和函数并持久化为快照。L23-52 of 583
typescript
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 — 负责对命令进行语义分类(读/写/高危)。L10-17 of 141
typescript
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` 等强制路径策略。L22-44 of 224
typescript
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 — 管理跨命令延续的全局环境变量脚本。L13-23 of 167
typescript
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
}

基于 Claude Code v2.1.88 开源快照的深度分析