# Bash：解析、只读判定与后台任务

> Bash 工具的输入与提示词、unbash 解析与只读判定规则体系、判定结果在权限层的含义、命令注册表生成、工作目录与 git 护栏、超时与自动转后台、输出截断与图片、TaskOutput 与 TaskStop，以及 Bash 读文件怎样回填读取状态。

- 作者：David（道雾轩）
- 专栏：ZCode 源码解读（https://daiw.org/manual/zcode.md）
- 最后更新：2026-09-21
- 原文：https://daiw.org/manual/zcode/bash
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

Bash 是 ZCode 里权限面最宽的内置工具之一。它的静态声明是 `readOnly: false`、`sideEffectScope: "system"`、`riskLevel: "high"`、`needsApproval: true`（`apps/zcode-cli/packages/core/src/tool/handlers/bash.ts:446`），但每次调用都会先被解析一遍：能证明是只读的命令当场降级为低风险、免审批；证明不了的，才按高风险走审批。这套判定加上后台生命周期、输出投影和读取状态联动，占了 `apps/zcode-cli/packages/core/src/tool/handlers/` 下 37 个 `bash*.ts` 文件（约 6100 行），外加一个 1.9 MB 的生成文件。

本篇只讲 Bash 这一侧。子进程怎样起、用哪个 shell、环境变量怎样清洗，见下一篇[执行边界](https://daiw.org/manual/zcode/exec-boundary)；后台任务的统一注册表与完成通知见[后台任务与通知](https://daiw.org/manual/zcode/background-tasks)；权限规则的语法与匹配顺序见[权限模式与规则](https://daiw.org/manual/zcode/permission)。

| 位置（`core/src/tool/handlers/` 下） | 职责 |
| --- | --- |
| `bash.ts`、`bash-prompt.ts`、`bash-background-*.ts` | 工具入口：组装执行请求，选前台、显式后台或超时转后台 |
| `bash-command-parser.ts` | 用 unbash 把命令拆成简单命令序列 |
| `bash-semantics.ts`、`bash-readonly-policy*.ts`（20 个） | 只读判定、退出码语义 |
| `bash-command-permission-policy.ts`、`bash-command-rule-evaluator.ts`、`generated/bash-command-registry.ts` | 权限规则的匹配主语与“始终允许”建议 |
| `bash-git-runtime-safety.ts`、`bash-cwd-policy.ts` | git 运行时护栏、工作目录保留与重置 |
| `bash-output.ts`、`bash-model-content.ts`、`bash-image-output.ts`、`bash-gh-rate-limit.ts` | 结果对象与模型可见文本 |
| `bash-read-file-sources.ts`、`bash-read-file-state.ts` | 与读取状态联动 |
| `task-output*.ts`、`task-stop.ts` | 读后台输出、停止后台任务 |

## 输入与提示词

输入 schema 在 `apps/zcode-cli/packages/contracts/src/tools/bash.ts:32`，是一个 `strict` 对象：

| 字段 | 含义 |
| --- | --- |
| `command` | 要执行的命令字符串 |
| `timeout` | 毫秒，描述里写明上限 600000（`contracts/src/tools/bash.ts:11`）；字符串数字也会被转成数字（`contracts/src/tools/bash.ts:78`） |
| `description` | 一句主动语态的说明，简单命令 5 到 10 个词，字段描述里带了 `ls`、`git status` 等示例（`contracts/src/tools/bash.ts:14`） |
| `run_in_background` | 显式后台运行 |
| `dangerouslyDisableSandbox` | 字面意思是“绕过沙箱”，但下一篇会看到它没有实际效果 |

两个布尔字段接受 `"true"`、`"yes"`、`"1"`、`"on"` 这类字符串（`contracts/src/tools/bash.ts:12`、`contracts/src/tools/bash.ts:88`），对模型偶尔吐出的字符串布尔值很宽容。

工具描述由 `apps/zcode-cli/packages/core/src/tool/handlers/bash-prompt.ts:1` 拼出，篇幅不长，核心是这两句（`bash-prompt.ts:13`、`bash-prompt.ts:16`）：

> Working directory persists between calls, but prefer absolute paths — `cd` in a compound command can trigger a permission prompt.
>
> `run_in_background` runs the command detached: it keeps running across turns and re-invokes you when it exits. No `&` needed.

其余几条：不要用 Bash 跑 `find`、`grep`、`cat`、`head`、`tail`、`sed`、`awk`、`echo`，改用专用工具（开启内嵌搜索时 `find`、`grep` 从名单里去掉，因为 shell 里的这两个命令已被替换，见[读、写、改、搜](https://daiw.org/manual/zcode/file-tools)）；超时的默认值与上限写成具体数字；Git 一节禁止交互式参数（`git rebase -i` 之类），要求用 `gh` 操作 GitHub，只在用户要求时提交或推送，在默认分支上先建分支（`bash-prompt.ts:18`）。

## 一次调用的路径

```mermaid
flowchart TD
  A["executeBashHandler"] --> B{"命令为空"}
  B -->|是| C["直接返回空结果"]
  B -->|否| D["createExecutionRequest：shell 模式、cwd、超时、输出上限"]
  D --> E{"run_in_background"}
  E -->|"是，但处于闲时回合"| F["报错：闲时任务不支持后台"]
  E -->|是| G["runBashWithBackgroundLifecycle explicit"]
  E -->|否| H{"首词是 sleep 或处于闲时回合"}
  H -->|否| I["runBashWithBackgroundLifecycle auto_on_timeout"]
  H -->|是| J["executionPort.run 普通前台"]
  G --> K["返回 backgroundTaskId"]
  I -->|到期仍在运行| K
  I -->|到期前结束| L["cwd 策略、toBashOutput、读取状态联动"]
  J --> L
```

入口 `executeBashHandler`（`apps/zcode-cli/packages/core/src/tool/handlers/bash.ts:104`）先组装执行请求（`handlers/bash.ts:388`）：命令以 `mode: "shell"`、`shellProfile: "posix-bash"` 交给执行端口，带上会话固定的 shell 选择；前台命令要求成功后回传最终工作目录（`captureCwdAfterSuccess`，`handlers/bash.ts:420`）；内联输出上限 30000 字节，后台一律落盘、前台只在截断时保留落盘文件（`handlers/bash.ts:422`）。然后在三条路里选一条（`handlers/bash.ts:172`）：

```ts
  const runCommand = async () =>
    parsed.run_in_background && backgroundLifecyclePort
      ? backgroundLifecyclePort.runBashWithBackgroundLifecycle(
          request,
          { mode: "explicit" },
          runOptions,
        )
      : eligibleForAutoBackground && backgroundLifecyclePort
        ? backgroundLifecyclePort.runBashWithBackgroundLifecycle(
            request,
            { mode: "auto_on_timeout" },
            runOptions,
          )
        : {
            kind: "foreground" as const,
            result: await executionPort.run(request, runOptions),
          };
```

注意中间那条：普通前台命令默认也走后台生命周期，模式是 `auto_on_timeout`。只有两种情况退回纯前台：命令首词是 `sleep`（`apps/zcode-cli/packages/core/src/tool/handlers/bash-background-policy.ts:3`），或者当前是闲时回合。闲时回合还会直接拒绝 `run_in_background`，注释解释了原因：后台命令完成后会另起一轮通知回合，而那一轮不带闲时模型，会落到用户自己的套餐上跑完整的 Agent 循环（`handlers/bash.ts:136`）。闲时任务本身见[定时任务与闲时任务](https://daiw.org/manual/zcode/cron-offpeak)。

命令运行 2 秒后，适配器每秒读一次输出文件尾部（`apps/zcode-cli/packages/adapters/src/exec/execution-utils.ts:14`），工具把它转成 `ToolCallProgress` 事件，带已用时间、字节数和输出预览，界面据此滚动显示（`handlers/bash.ts:359`）。

## 命令解析：unbash

解析用的是 bash 解析库 unbash，版本锁在 4.0.1（`apps/zcode-cli/packages/core/package.json:44`）。`analyzeBashCommand`（`apps/zcode-cli/packages/core/src/tool/handlers/bash-command-parser.ts:51`）的产出是一串“简单命令调用”，每条记下 argv、原文片段、前缀环境赋值、重定向和它前面的连接符（`&&`、`||`、`|`、`|&` 或顺序执行，`bash-command-parser.ts:14`）。几个处理原则：

- **只展开四种节点**。`Statement`、`AndOr`、`Pipeline` 是容器，`Command` 是简单命令；其余任何节点类型都只记进 `unsupportedNodeTypes`，不再往里走（`bash-command-parser.ts:129`）。从代码看，括号子 shell、`if`/`for`/`while`、函数定义这类复合结构都不在这四种之内。语句末尾的 `&` 也记为一种不支持的语法（`bash-command-parser.ts:115`）。
- **长度与错误**：超过 10000 个字符直接按解析失败处理（`bash-command-parser.ts:48`）；`parse` 抛异常，或返回的 `errors` 非空，同样记为解析失败（`bash-command-parser.ts:64`、`bash-command-parser.ts:76`）。
- **重定向**：语句级与命令级的重定向合并到每条命令上，目标和 here-doc 正文也要检查是否动态（`bash-command-parser.ts:166`、`bash-command-parser.ts:207`）。
- **动态词**：命令替换、进程替换会在主命令之前执行，权限判断不能把它们当普通参数。判定按词的组成部分逐一看：只有字面量、单引号和 ANSI-C 引号（`$'...'`）算静态，双引号要看里面的内容；命令替换、进程替换、变量展开、算术展开、花括号展开、扩展 glob 与未知类型一律当动态（`bash-command-parser.ts:221`）。

解析失败、有不支持的语法、有动态词，三项之一成立，命令就不“权限安全”（`bash-command-parser.ts:92`），此后既不可能判为只读，也不会被拆开去匹配前缀规则。

## 只读判定

整条命令的判定在 `isRuntimeReadOnlyBashCommand`（`apps/zcode-cli/packages/core/src/tool/handlers/bash-semantics.ts:39`）：先要求权限安全、至少有一条命令；同一条命令里既有 `git` 又有 `cd`、`pushd`、`popd` 时直接否决，因为 git 可能在目标目录加载 hooks 与配置，而 `cd && grep` 这种仍可放行（`apps/zcode-cli/packages/core/src/tool/handlers/bash-git-runtime-safety.ts:37`）；含 git 且当前目录的 git 环境可疑时也否决（见下文“git 护栏”）。然后逐条判定，**每一条**都必须明确判为只读，有一条未知就整体不是只读（`bash-semantics.ts:52`）。

单条命令的判定是一串短路（`apps/zcode-cli/packages/core/src/tool/handlers/bash-readonly-policy-argv.ts:25`）：

```ts
export function evaluateBashReadonlyPolicy(
  commandPart: BashCommandInvocation,
): boolean | undefined {
  if (!areEnvAssignmentsAllowed(commandPart)) return false;
  if (!areRedirectsAllowed(commandPart)) return false;

  const argv = stripSafeCommandWrappers(commandPart.argv);
  if (argv.length === 0) return false;
  if (argv.some(isUnsafeWindowsUncPath)) return false;
  if (argv[0] === "git") return isGitReadOnlyCommand(argv);

  const directArgvResult = evaluateDirectReadonlyArgv(argv);
  if (directArgvResult !== undefined) return directArgvResult;

  const prefixPolicyResult = evaluateReadonlyPrefixPolicy(argv, commandPart.commandText);
  if (prefixPolicyResult !== undefined) return prefixPolicyResult;

  if (READONLY_ALLOW_ANY_ARG_COMMANDS.has(argv[0] ?? "")) return true;
  if (process.platform === "win32" && argv[0] === "xargs") return undefined;

  const policy = READONLY_COMMAND_POLICIES.get(argv[0] ?? "");
  if (!policy) return undefined;
  if (argv[0] === "cd" && argv.length > 2) return false;
  if (policy.additionalCommandIsDangerousCallback?.(commandPart.commandText, argv.slice(1)))
    return false;
  if (!isArgvAllowedByPolicy(argv, policy, argv[0] ?? "")) return false;
  if (policy.regex && !policy.regex.test(commandPart.commandText)) return false;
  return true;
}
```

在它之前还有一道 `hasKnownBashWriteOption`：`sed -i`、`find` 的 `-delete`/`-exec`/`-fprint` 等 10 个写选项、`tree -o`、git 的危险全局参数，出现即否决（`bash-readonly-policy-argv.ts:55`）。各层的内容：

| 层 | 规则 | 出处 |
| --- | --- | --- |
| 环境赋值前缀 | 只许 39 个无害变量，如 `LANG`、`TZ`、`NO_COLOR`、`CI`、`GIT_TERMINAL_PROMPT` | `apps/zcode-cli/packages/core/src/tool/handlers/bash-readonly-policy-argv-io.ts:3` |
| 重定向 | 只许输入重定向 `<`、`<<`、`<&`、`<<<`；输出只许写到 `/dev/null` 或 `>&N`；`/dev/tcp`、`/dev/udp` 一律拒 | `bash-readonly-policy-argv-io.ts:53` |
| 包装词 | 剥掉 `command`、`builtin`、`noglob` 再看真正的命令；Windows UNC 路径拒 | `bash-readonly-policy-argv-io.ts:75` |
| 精确 argv | `ip addr`、`node -v`、`python3 --version` 等；`docker images`/`ps` 另查危险全局参数；`printf`、`find`、`history`、`arch`、`ifconfig` 各有专门检查 | `apps/zcode-cli/packages/core/src/tool/handlers/bash-readonly-policy-argv-direct.ts:69` |
| 多词前缀 | 24 条：`docker inspect`、`docker logs`、`gh auth status`，以及 21 条 `gh` 只读命令（`gh pr view`、`gh run list`、`gh search code` 等） | `apps/zcode-cli/packages/core/src/tool/handlers/bash-readonly-policy-commands.ts:21` |
| 任意参数 | 46 个命令不看参数：`cat`、`head`、`tail`、`wc`、`diff`、`stat`、`uname`、`which`、`sleep` 等 | `apps/zcode-cli/packages/core/src/tool/handlers/bash-readonly-policy-simple-commands.ts:171` |
| 标志表 | 60 个命令按安全标志表逐个检查参数，部分附加回调 | `bash-readonly-policy-simple-commands.ts:59` |
| git | 24 个只读子命令，各有标志表 | `bash-readonly-policy-commands.ts:16` |

**标志表**是这套规则的主体。每个安全标志声明取值类型：`none`、`number`、`string`、`optionalString`、`char`，以及只能是字面 `{}` 或 `EOF` 的两种（`apps/zcode-cli/packages/core/src/tool/handlers/bash-readonly-policy-types.ts:1`）。检查器支持 `--flag=value`、`-n5` 这类粘连写法和 `-la` 这类短标志簇（簇里每个字母都必须是 `none` 类型），`head`/`tail` 的 `-20` 特判放行，位置参数不限，遇到 `--` 停止检查；表里没有的标志一律拒（`apps/zcode-cli/packages/core/src/tool/handlers/bash-readonly-policy-argv-flags.ts:18`）。标志表管不住的语义交给回调：

- `sed` 脚本里出现 `w` 写文件命令即否决（`apps/zcode-cli/packages/core/src/tool/handlers/bash-readonly-policy-callbacks.ts:82`）；
- `jq` 禁 `-f`、`-L`、`--rawfile` 等读外部文件的选项，过滤器里出现 `$ENV`、`env`、`include`、`import` 也否决（`bash-readonly-policy-callbacks.ts:5`）；
- `date` 的位置参数必须以 `+` 开头，否则可能是在设置系统时间（`bash-readonly-policy-callbacks.ts:86`）；`ps` 禁含 `e` 的 BSD 风格参数，它会显示进程的环境变量（`bash-readonly-policy-callbacks.ts:104`）；
- `xargs` 只能驱动 8 个命令：`echo`、`printf`、`wc`、`grep`、`egrep`、`fgrep`、`head`、`tail`（`bash-readonly-policy-callbacks.ts:269`），在 Windows 上 `xargs` 一律算未知；
- `gh` 的参数值若含 `://`、`@` 或两个以上 `/`，就可能指向别的主机，否决（`bash-readonly-policy-callbacks.ts:298`）。

从代码看有一处重叠：`cat`、`head`、`tail`、`wc` 等既在标志表里，又在“任意参数”集合里。任意参数的检查排在标志表之前（`bash-readonly-policy-argv.ts:42`），所以这些命令的标志表实际不生效。

**git** 先规范化全局参数：只允许 `--no-pager`、`--paginate`；`-c`、`-C`、`--git-dir`、`--work-tree`、`--exec-path`、`--config-env`、`--namespace`、`--super-prefix`、`--shallow-file`、`--attr-source`、`--bare` 这 11 个会改变 git 读哪份配置、在哪个仓库执行的参数，出现即否决（`bash-readonly-policy-simple-commands.ts:45`）。子命令按最长前缀匹配（`apps/zcode-cli/packages/core/src/tool/handlers/bash-readonly-policy-argv-git.ts:12`），再过回调：`git log`、`git show`、`git rev-list`、`git shortlog`、`git for-each-ref` 的格式串里出现 `%G` 或 `%(signature` 会触发 GPG 验签，否决（`apps/zcode-cli/packages/core/src/tool/handlers/bash-readonly-policy-git-callbacks.ts:15`）；`git branch`、`git tag` 不带 `--list`/`-l` 又有位置参数，就是在创建分支或标签，否决（`bash-readonly-policy-git-callbacks.ts:92`）；`git reflog` 只许 `show`、`list`；`git ls-remote` 带位置参数会联网，否决；`git remote show` 必须带 `-n`（`bash-readonly-policy-git-callbacks.ts:21`、`:32`、`:52`）。

## 判定为只读意味着什么

判定结果通过工具入口的 `resolvePermissionCapability` 生效（`handlers/bash.ts:77`）：只读命令的能力被覆盖为 `readOnly: true`、`needsApproval: false`、`riskLevel: "low"`、`sideEffectScope: "none"`。权限服务据此在各模式下分流（`apps/zcode-cli/packages/core/src/permission/service.ts:97`）：

| 模式 | 只读 Bash | 非只读 Bash |
| --- | --- | --- |
| `build`、`edit` | 直接放行，规则 `mode.build.readOnly`（`service.ts:456`） | 询问，规则 `mode.build.highRisk`（`service.ts:474`） |
| Plan（`planEnabled` 为真） | 放行，规则 `mode.plan.readOnly`（`service.ts:408`） | **拒绝**而不是询问，规则 `mode.plan.nonReadOnly`（`service.ts:443`） |
| `yolo` 且不在 Plan | 放行（`service.ts:136`） | 放行 |

Plan 模式的分支排在项目 `allow` 规则之前（`service.ts:176`），所以审批时选“始终允许”存下的项目规则（比如 `npm test:*`）在规划期间也不生效；项目级 `deny`、`ask` 规则则更早（`service.ts:158`）。

还要注意，只读判定只看命令与参数的形态，不看路径：从代码看，`cat`、`head` 读工作区之外的绝对路径同样算只读，在 `build` 模式下也免审批。

同一套判定还用在别处：动态工作流在 `ToolCallStarted` 事件上读“这一笔是否会改写工作区”（`apps/zcode-cli/packages/core/src/tool/executor/permission-capability.ts:28`）；记忆 Agent 只放行只读 Bash（`apps/zcode-cli/packages/core/src/memory/memory-agent-loop.ts:147`，见[项目记忆](https://daiw.org/manual/zcode/memory)）。

**规则匹配的主语**。`resolveBashPermissionRulePolicy`（`apps/zcode-cli/packages/core/src/tool/handlers/bash-command-permission-policy.ts:85`）把命令拆成每条简单命令的“主语”，交给 `evaluateBashRules`（`apps/zcode-cli/packages/core/src/tool/handlers/bash-command-rule-evaluator.ts:13`）：

- 规则内容与整条命令逐字相等，总是命中；
- 命令不权限安全，或含重定向、非静态的环境赋值，就**只**认逐字相等（`bash-command-permission-policy.ts:169`）；
- `allow` 规则要覆盖每一条**非只读**的子命令，只读的部分自动豁免，所以 `cd app && npm test` 只需要一条 `npm test:*`；
- `deny`、`ask` 规则命中任意一条子命令即生效；
- `前缀:*` 形式按词边界匹配，其余带 `*` 的走通配（`bash-command-rule-evaluator.ts:40`）。

## 命令注册表：给“始终允许”找稳定前缀

用户在审批时选“始终允许”，ZCode 要提议一条规则。逐字规则太窄，整个 `git:*` 又太宽，所以需要知道一条命令的哪几个词是“动作”。`buildSuggestedUpdates`（`bash-command-permission-policy.ts:135`）为每条非只读子命令算一个稳定前缀，拼成 `前缀:*`，最多 5 条，超过或算不出就退回逐字规则（`bash-command-permission-policy.ts:16`）。前缀的算法在 `resolveStableCommandPrefix`（`bash-command-permission-policy.ts:187`）：剥掉最多两层 `env`、`sudo`、`nohup`、`time`、`command` 包装；`rm`、`chmod`、`dd`、`mkfs`、`sh`、`bash`、`powershell` 等 16 个高风险根命令永远不给前缀（`bash-command-permission-policy.ts:19`）；几类有固定深度：`python -m 模块`、`npm`/`pnpm`/`yarn`/`bun run 脚本`、`deno task`、`make`/`just 目标`、`aws`/`az` 取两词、`gcloud` 取三词（`bash-command-permission-policy.ts:68`）；其余查注册表，跳过已知选项（带参数的选项连参数一起跳），沿子命令树往下走。

| 命令 | 建议的规则 |
| --- | --- |
| `pnpm --dir app run lint` | `pnpm run lint:*`（`--dir` 被识别为带参数的全局选项并丢弃） |
| `git -C sub commit -m "x"` | `git commit:*` |
| `python3 -m pytest tests` | `python3 -m pytest:*` |
| `rm -rf dist` | 逐字规则 `rm -rf dist` |

注册表来自 Fig 的命令行补全库。生成脚本 `apps/zcode-cli/scripts/generate-bash-command-registry.mjs:15` 要求 `@withfig/autocomplete` 恰好是 2.692.3（版本检查在 `generate-bash-command-registry.mjs:52`），逐个 import 包里的补全规格，压成四元组 `[名字, 选项, 参数标志, 子命令]`：选项只记是否带参数；参数标志用位表示是否是命令、模块、可变长、可选、危险、文件、目录（`generate-bash-command-registry.mjs:23`、`:162`）；函数式的动态子命令与只含 `loadSpec` 的节点被跳过（`generate-bash-command-registry.mjs:141`、`:149`）。输出按名字排序、附上构建文件的 sha256，体积超过 3 MiB 就报错（`generate-bash-command-registry.mjs:16`、`:86`）。`--check` 模式在临时目录重新生成、与仓库里的文件逐字节比较（`generate-bash-command-registry.mjs:92`），挂在 `pnpm check` 上（`apps/zcode-cli/package.json:10`、`:23`）。仓库里的产物 1906984 字节，含 707 个根命令名（含别名），文件头记录跳过了 522 个 `loadSpec` 节点（`apps/zcode-cli/packages/core/src/tool/handlers/generated/bash-command-registry.ts:4`）。

同类思路可以对照 [OpenCode 的 Shell 工具](https://daiw.org/manual/opencode/shell-tool)：那边用 tree-sitter 解析命令，按命令元数生成“总是允许”规则。

## 工作目录与 git 护栏

**工作目录**。Bash 每次都起新 shell，只把最终目录带回来：前台命令在原命令后追加一段，退出码为 0 时把 `pwd -P` 写进临时文件（`apps/zcode-cli/packages/adapters/src/exec/cwd-capture.ts:51`），注释明说环境变量、别名、函数都不会保留（`cwd-capture.ts:32`）。`decideBashCwdPolicy`（`apps/zcode-cli/packages/core/src/tool/handlers/bash-cwd-policy.ts:33`）只在命令成功、且是主会话（子 Agent 不算）时生效（`bash-cwd-policy.ts:37`）：新目录在工作区内就保留；离开工作区则重置回工作区根，并在 stderr 末尾追加 `Shell cwd was reset to …`（`bash-cwd-policy.ts:47`）。比较时同时看字面路径与真实路径，macOS 的 `/private/tmp`、`/private/var` 先归一（`bash-cwd-policy.ts:104`）。

**git 运行时护栏**。代码里没有“禁止 `git push --force`”这类硬拦截：破坏性 git 命令只是判不成只读，按普通高风险命令走审批，`yolo` 下照样执行。护栏针对的是另一类风险：一个恶意仓库的 `.git` 配置可以让看似只读的 `git status` 执行任意程序。`isGitRuntimeContextUnsafe`（`bash-git-runtime-safety.ts:71`）从当前目录往上找 `.git`：

- `.git` 是符号链接或 `gitdir:` 文件时，目标解析后若落在当前目录之内、或路径里没有 `.git` 这一段，判为不安全；`gitdir` 文件超过 32 KiB 或含 NUL 也不安全（`bash-git-runtime-safety.ts:98`、`:124`）；
- `.git` 目录要有合法的 `HEAD`、可进入的 `objects` 与 `refs`、且没有 `commondir`，才算可信（`bash-git-runtime-safety.ts:141`）；
- 在找到可信的 `.git` 之前，路上任何一层出现 `HEAD` 文件或 `objects`、`refs` 目录，就当成裸仓库，不安全（`bash-git-runtime-safety.ts:172`）。

判为不安全后，这条命令里的任何 git 调用都不再是只读，回到审批。

## 超时与后台

| 数字 | 值 | 出处 |
| --- | --- | --- |
| 默认超时 | 120000 毫秒，环境变量 `BASH_DEFAULT_TIMEOUT_MS` 可改 | `apps/zcode-cli/packages/core/src/tool/bash-timeout-policy.ts:6`、`:18` |
| 超时上限 | 600000 毫秒，`BASH_MAX_TIMEOUT_MS` 可改，但不低于默认值 | `bash-timeout-policy.ts:7`、`:23` |
| 本次超时 | `timeout` 与默认值取其一，再与上限取小；0 视同未给 | `bash-timeout-policy.ts:34` |
| 执行器看门狗 | 上面的值再加 6000 毫秒清理宽限 | `handlers/bash.ts:497`、`apps/zcode-cli/packages/core/src/tool/executor/timeout.ts:203` |
| 子 Agent 的后台 Bash | 最长 3600000 毫秒，到点取消 | `apps/zcode-cli/packages/core/src/runtime/helpers/runtime-tools.ts:25` |

两个环境变量在组装运行时配置时读取（`apps/zcode-cli/packages/bootstrap/src/app/runtime-config.ts:120`）；代码里没有按命令名调整超时的表。

关键在于“超时”对前台命令意味着什么。`runBashWithBackgroundLifecycle`（`apps/zcode-cli/packages/adapters/src/exec/node-execution-adapter-lifecycle.ts:102`）把真正的进程超时设为 0、输出改为始终落盘（`node-execution-adapter-lifecycle.ts:123`），另挂一个“前台期限”计时器；期限一到，进程并不被杀，而是原地转为后台任务（`node-execution-adapter-lifecycle.ts:190`）：

```ts
    const commitBackground = () => {
      if (state !== "foreground" || controller.signal.aborted) return false;

      // 旧 explicit background 复用了通用 start()，foreground timeout 与
      // parent turn abort 会继续挂在子进程上。这里先原子提交状态，再同步清理 deadline、
      // 脱离 parent abort 并登记 task，避免 abort/completion 在提交缝隙里误杀后台进程。
      state = "backgrounded";
      bashLifecycle?.onBackgrounded?.();
      clearForegroundDeadline();
      removeExternalAbort();
      this.backgroundTasks.set(taskId, record);
      if (persistedLimitReached) {
        controller.abort("output_limit");
      }
      resolveOutcome({
        kind: "backgrounded",
        task: {
          taskId,
          status: "running",
          startedAt,
          pid: record.pid,
          ...outputPaths,
        },
      });
      return true;
    };
```

显式后台的区别只是：进程一启动（收到 `started` 事件）就立即提交（`node-execution-adapter-lifecycle.ts:245`）。两种情况下，工具都返回 `status: "backgrounded"` 与任务 ID，模型看到 `Command running in background with ID: …`，外加输出文件路径和“用 Read 看中间输出”的提示（`apps/zcode-cli/packages/core/src/tool/handlers/bash-model-content.ts:119`）。所以在 ZCode 里，只有 `sleep` 开头的命令和闲时回合里的命令会真正“超时被杀”，普通命令超时的结果是变成后台任务；转入后台后主会话里不再有超时，只剩输出文件 5 GB 的上限（见下一篇）。执行器随后把它登记进运行时任务表，完成时注入 `<task-notification>` 再起一轮（`apps/zcode-cli/packages/core/src/tool/executor/background-tasks.ts:91`），细节见[后台任务与通知](https://daiw.org/manual/zcode/background-tasks)。

同一个格式化函数里还有两段文案：“超过 assistant 模式 15 秒阻塞预算，被移到后台”与“用户手动转后台”（`bash-model-content.ts:122`、`:125`）。对应的 `assistantAutoBackgrounded`、`backgroundedByUser` 字段只在契约里声明，全仓库没有任何代码给它们赋值，TUI 里也没有手动转后台的按键。从代码看，这两段是没有接线的遗留文案。

## 读后台输出与停止

**TaskOutput** 的契约在 `apps/zcode-cli/packages/contracts/src/tools/task-output.ts:4`，带着 `AgentOutputTool`、`BashOutputTool`、`AgentOutput`、`BashOutput` 四个别名（`contracts/src/tools/task-output.ts:5`）。它的描述开头就是 `DEPRECATED`，建议对 bash 任务直接用 Read 读输出文件（`contracts/src/tools/task-output.ts:12`）。参数是 `task_id`、`block`（默认 `true`）和 `timeout`（默认 30000、最大 600000 毫秒，`contracts/src/tools/task-output.ts:29`）。处理逻辑在 `apps/zcode-cli/packages/core/src/tool/handlers/task-output.ts:40`：

- `block=false` 且任务仍在运行，返回 `not_ready`；`block=true` 时每 100 毫秒轮询一次，到时仍未结束返回 `timeout`（`handlers/task-output.ts:34`、`handlers/task-output.ts:229`）；
- Bash 任务运行中只读输出文件开头 30000 字节，结束后读结尾 8 MiB（`apps/zcode-cli/packages/core/src/tool/handlers/task-output-projection.ts:10`、`apps/zcode-cli/packages/core/src/tool/handlers/task-output-bash.ts:120`）；
- 给模型的文本最多 32000 字符，保留尾部并在前面注明完整文件路径；`TASK_MAX_OUTPUT_LENGTH` 可调，上限 160000（`handlers/task-output.ts:199`）；
- 读到终态才把任务标记为 `notified`，而且先投影、再检查中止信号、最后写标记，避免吞掉完成通知（`handlers/task-output.ts:60`）。

描述里还说任务 ID 可以用 `/tasks` 命令查看（`contracts/src/tools/task-output.ts:22`），但 ZCode 的内置斜杠命令表里没有 `/tasks`（`packages/shared/src/zcode-slash-command-help.ts:9`）。

**TaskStop**（`apps/zcode-cli/packages/core/src/tool/handlers/task-stop.ts:96`）别名 `KillShell`、`KillBash`，参数 `task_id`，旧名 `shell_id` 仍兼容（`task-stop.ts:29`）。它调用后台任务控制端口，发起方标为 `model`，这样终态通知会写“被你停止”而不是“被用户停止”（`task-stop.ts:50`）；自身超时 10000 毫秒，不可覆盖（`task-stop.ts:136`）。

界面上的后台任务详情走另一条路：运行时方法 `readBackgroundBashOutput`（`apps/zcode-cli/packages/core/src/runtime/methods/background-bash-output.ts:5`）由 ZCode Protocol 的 `backgroundBashOutput` 查询调用（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/v4-gateway.ts:1906`），适配器按启动时的会话 ID 校验归属，只返回输出文件结尾 8192 字节（`node-execution-adapter-lifecycle.ts:300`、`packages/shared/src/background-bash-output.ts:3`）。

## 输出：截断、退出码与图片

Bash 的 stdout 与 stderr 由子进程直接写进同一个文件（下一篇细说），所以结果里 stderr 通常为空，stdout 是合并后的输出。给模型的内容由 `formatBashModelContent`（`bash-model-content.ts:14`）生成：

- **截断**：内联部分取输出**开头** 30000 字节（`apps/zcode-cli/packages/adapters/src/exec/bash-file-output.ts:132`），适配器侧可用 `BASH_MAX_OUTPUT_LENGTH` 调到最多 150000（`apps/zcode-cli/packages/adapters/src/exec/bash-output-policy.ts:1`）。超出时完整文件保留，模型看到一个 `<persisted-output>` 信封：总大小、完整路径、开头 2000 字符预览（`bash-model-content.ts:10`、`apps/zcode-cli/packages/core/src/tool/result-persistence-format.ts:29`）。没超出的前台输出文件在结算时删除（`apps/zcode-cli/packages/adapters/src/exec/node-execution-adapter-results.ts:94`）。
- **退出码**：非零退出码不抛异常，作为结果字段返回（`contracts/src/tools/bash.ts:123`）。`interpretBashReturnCode` 按链上最后一条命令给出语义：`grep`、`rg` 退出 1 是 “No matches found”，`diff` 是 “Files differ”，`find` 是 “Some directories were inaccessible”，`test`/`[` 是 “Condition is false”，`git grep`、`git diff` 同样识别（`bash-semantics.ts:134`）。这四种不算错误；其余非零退出码在模型内容开头写 `Exit code N`，并把工具结果标为错误（`bash-model-content.ts:50`、`apps/zcode-cli/packages/core/src/runtime/helpers/tool-result.ts:74`）。被中断的命令追加 `<error>Command was aborted before completion</error>`（`bash-model-content.ts:105`）。
- **图片**：如果整个 stdout 恰好是一个 `data:image/…;base64,…` 形式的 URL，就作为图片块交给模型（`apps/zcode-cli/packages/core/src/tool/handlers/bash-image-output.ts:20`）。落盘的输出最多读 20 MiB；有图像处理端口时缩放到长宽都不超过 2000 像素（`bash-image-output.ts:12`、`apps/zcode-cli/packages/contracts/src/tools/read.ts:20`）；缩放失败但图片头校验通过时用原图，解码失败则退回文本。
- **gh 限流提示**：命令里调用了 `gh`（`auth`、`help`、`version`、`alias`、`completion`、`config` 除外），输出又匹配 “API rate limit exceeded” 等字样时，追加一条 `system-reminder`，说明 5000 次每小时的配额由所有工具与 Agent 共享，要求先查 `gh api rate_limit` 再睡到重置；同一进程 60 秒内只提示一次（`apps/zcode-cli/packages/core/src/tool/handlers/bash-gh-rate-limit.ts:1`）。提示还让模型轮询时改用 `ScheduleWakeup`，但 ZCode 并没有注册这个工具，它只出现在一张工具排序名单里（`apps/zcode-cli/packages/core/src/tool/provider-visible-order.ts:19`）。

## Bash 读文件与读取状态

Edit、Write 要求“先读后写”（见[读、写、改、搜](https://daiw.org/manual/zcode/file-tools)）。模型常用 Bash 看文件，所以 Bash 结束后 `applyBashReadFileStateEffects` 做两件事（`apps/zcode-cli/packages/core/src/tool/handlers/bash-read-file-state.ts:51`）。

**回填**。`collectBashReadFileSources`（`apps/zcode-cli/packages/core/src/tool/handlers/bash-read-file-sources.ts:44`）只认几种一眼能看懂的形态，且整条命令不能含 `|`、`<`、`>`：`cat 文件`（可带 `-n`）、`head`/`tail -n N 文件`（默认 10 行）、`sed -n` 加 `N,Mp` 或 `Np` 脚本，以及单独一条的 `grep 模式 文件`（必须退出码为 0）；多条命令时夹在中间的 `echo`、`printf`、`true`、`:` 可以忽略。满足条件、stdout 没被截断、文件不超过 10 MiB、之前没有记录，就按命令实际展示的内容写一条读取记录（`bash-read-file-state.ts:95`）：`head`、`tail`、`sed -n` 记下行范围，`cat`、`grep` 不带范围，等同整文件读取。这些记录一律不标记为部分视图（`bash-read-file-state.ts:149`），而 Edit 判断“没读过”只看有没有记录、是不是部分视图（`apps/zcode-cli/packages/core/src/tool/handlers/edit.ts:429`），所以一次 `head`、甚至一次命中的 `grep` 之后，模型就可以直接 Edit 这个文件。

**过期提示**。命令匹配格式化类标记（`--write`、`--fix`、`--in-place`、`black`、`cargo fmt`、`go fmt`、`terraform fmt` 等 19 种，`bash-read-file-state.ts:20`）时，逐个 stat 已读过的文件，修改时间晚于命令开始、且晚于记录的，汇总成一句 `[This command modified N files you've previously read: … Call Read before editing.]`，最多列 5 个路径（`bash-read-file-state.ts:182`）。后台命令、图片输出、被中断或出错的结果，这两件事都不做（`bash-read-file-state.ts:168`）。

## Shell 选择随会话固定

用哪个 shell 在会话创建时决定，之后不再变。`persistBashShellSelectionSnapshot` 把选择写成一条会话条目，ID 是会话 ID 加 `:runtime:bash_shell_selection`（`apps/zcode-cli/packages/core/src/runtime/methods/bash-shell-snapshot.ts:27`），注释说明理由：shell 设置变更只影响新会话，冷恢复必须继续用同一个 shell（`bash-shell-snapshot.ts:48`）。恢复时读最新一条：快照里的路径仍可执行就是 `restored`；不可用了且当前有候选就 `fallback` 到当前候选，否则 `stale`（`bash-shell-snapshot.ts:104`）。Windows 上字面的 `cmd.exe` 不做可执行检查（`bash-shell-snapshot.ts:196`）。shell 候选怎样挑出来、恢复后怎样提醒模型 shell 变了，见下一篇。

下一篇：[执行边界：子进程、环境与网络](https://daiw.org/manual/zcode/exec-boundary)——命令最终怎样 spawn、登录 Shell 快照怎么取、代理与证书变量怎样封存再还给子进程，以及为什么没有操作系统沙箱。
