# Shell 工具

> 工具 ID 为何仍叫 bash、shell 的选择与说明模板、超时中止与输出截断，以及用 tree-sitter 解析命令、按命令元数生成“总是允许”规则。

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

Shell 工具实现在 `packages/opencode/src/tool/shell.ts`，说明模板与参数在 `tool/shell/` 子目录。先看一个细节：不管底层是 bash、PowerShell 还是 cmd，工具 ID 都叫 `bash`。

> Keep the exposed tool ID and permission key as "bash" for compatibility with existing plugins, users, and saved permissions. Rename with opencode 2.0.

（`packages/opencode/src/tool/shell/id.ts:14`）

工具 ID 同时是权限键，改名会让用户写好的 `bash` 权限规则全部失效，只能留到 2.0。

## 参数与 shell 选择

参数只有三个：`command`、`timeout`（毫秒）和 `workdir`。V1 的 Shell 工具没有“后台运行”参数（子代理的后台模式见[下一篇](https://daiw.org/manual/opencode/task-and-todo)）。

用哪个 shell 取决于配置项 `shell`，缺省取环境变量 `SHELL`，都要经过 `Shell.acceptable` 过滤：

```ts
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
  bash: { login: true, posix: true },
  dash: { login: true, posix: true },
  fish: { deny: true, login: true },
  ksh: { login: true, posix: true },
  nu: { deny: true },
  powershell: { ps: true },
  pwsh: { ps: true },
  sh: { login: true, posix: true },
  zsh: { login: true, posix: true },
}
```

（`packages/core/src/shell.ts:13`）

fish 与 nu 这两个非 POSIX 的 shell 被标为 `deny`，会退回默认值：Windows 上依次尝试 pwsh、powershell、Git Bash 和 `COMSPEC`（通常是 cmd.exe），macOS 用 `/bin/zsh`，其他系统找 bash，最后兜底 `/bin/sh`。

## 说明按 shell 渲染

`shell/prompt.ts` 以 `shell/shell.txt` 为模板，按 shell 种类填入不同段落：bash 版讲引号与 `&&` 串联；PowerShell 7 和 5.1 分开写，因为 5.1 不支持 `&&`，要写成 `cmd1; if ($?) { cmd2 }`；cmd.exe 版讲 `%VAR%` 与 `if exist`。默认超时、截断阈值这些数字在渲染时插入，说明与代码不会漂移。

模板里还有两点值得注意：一是明令文件操作改用专门工具，不要用 `cat`、`sed`、`echo` 重定向；二是告诉模型 opencode 的临时目录已预先批准外部访问，对应 agent 默认规则对 `Global.Path.tmp` 的放行（见[权限系统](https://daiw.org/manual/opencode/permission)）。

## 启动进程

```ts
function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
  if (process.platform === "win32" && Shell.ps(shell)) {
    return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
      cwd,
      env,
      stdin: "ignore",
      detached: false,
    })
  }

  return ChildProcess.make(command, [], {
    shell,
    cwd,
    env,
    stdin: "ignore",
    detached: process.platform !== "win32",
  })
}
```

（`packages/opencode/src/tool/shell.ts:293`）

`stdin: "ignore"` 让等待输入的交互式命令立刻读到 EOF，而不是挂到超时；非 Windows 下以 `detached` 启动，子进程自成一个新的进程组；环境变量是 `process.env` 叠加插件钩子 `shell.env` 的返回值，插件可以借此追加变量。

这里用的是普通子进程，没有 TTY。`packages/core/src/pty.ts` 虽然提供伪终端，但它服务的是客户端里的交互式终端（服务端有对应的 pty 路由），与 Shell 工具无关。

## 超时、中止与输出

默认超时 2 分钟，可用 `OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS` 修改。执行时让三件事赛跑：

```ts
const exit = yield* Effect.raceAll([
  handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
  abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
  timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
])
```

（`packages/opencode/src/tool/shell.ts:542`）

中止或超时都会 `handle.kill({ forceKillAfter: "3 seconds" })`，并在输出末尾追加 `<shell_metadata>`；超时时还明确建议模型：命令若不是在等交互输入，就用更大的 timeout 重试。

输出处理比通用截断讲究：

- 读取 stdout 与 stderr 的合流，每来一块就用 `ctx.metadata` 推送最近 30000 字符的预览，TUI 实时滚动；
- 内存只保留最近约两倍 `max_bytes` 的数据；累计超过 `max_bytes` 后，全量输出改为流式追加到截断目录的文件；
- 结束时取**尾部**的 `max_lines` 行或 `max_bytes` 字节作为输出（报错通常在最后），前面注明 `...output truncated...` 和全文路径；
- 元数据带上 `exit` 退出码，并显式设置 `truncated`，通用截断不再重复处理。

## 用 tree-sitter 解析命令

权限判定是 Shell 工具最有意思的部分。执行前，`parser()` 懒加载 web-tree-sitter 与 bash、PowerShell 两份 wasm 语法，把命令解析成语法树，`collect()` 遍历其中每个 `command` 节点（删减后）：

```ts
for (const node of commands(root)) {
  const command = parts(node)
  const tokens = command.map((item) => item.text)
  const cmd = ps || shellKind === "cmd" ? tokens[0]?.toLowerCase() : tokens[0]

  if (cmd && (FILES.has(cmd) || (shellKind === "cmd" && CMD_FILES.has(cmd)))) {
    for (const arg of pathArgs(command, ps, shellKind === "cmd")) {
      const resolved = yield* argPath(arg, cwd, ps, shell)
      // ...
      if (!resolved || containsPath(resolved, instance)) continue
      const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved)
      scan.dirs.add(dir)
    }
  }

  if (tokens.length && (!cmd || !CWD.has(cmd))) {
    scan.patterns.add(source(node))
    scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
  }
}
```

（`packages/opencode/src/tool/shell.ts:392`）

它做两件事：

1. **找越界路径**。对 `rm`、`cp`、`mv`、`mkdir`、`touch`、`chmod`、`chown`、`cat`、`cd` 一族，以及 `Remove-Item` 等 PowerShell cmdlet（cmd.exe 另有 `del`、`copy` 等），逐个解析路径参数：跳过选项，展开 `~`（PowerShell 还会展开环境变量），含变量或命令替换等动态片段的参数放弃判断，带通配符的只取通配符之前的部分。落在项目之外的目录记入 `scan.dirs`；工作目录本身在项目外也算。
2. **生成权限模式**。每条非 `cd` 类命令的原文进入 `patterns`，同时按命令元数生成一条 `always` 模式。

随后先询问 `external_directory`（如有越界目录），再以 `bash` 为权限键询问全部命令。`git status && rm -rf build` 会被拆成两个模式分别求值，`bash` 下一条 `"rm *": "deny"` 就能拦下整次调用。

## 命令元数：总是允许什么

“总是允许”如果存整条命令，参数一变就失效；如果存整个工具，又等于放开一切。`permission/arity.ts` 取中间粒度，即命令的有效前缀：

```ts
export function prefix(tokens: string[]) {
  for (let len = tokens.length; len > 0; len--) {
    const prefix = tokens.slice(0, len).join(" ")
    const arity = ARITY[prefix]
    if (arity !== undefined) return tokens.slice(0, arity)
  }
  if (tokens.length === 0) return []
  return tokens.slice(0, 1)
}
```

（`packages/opencode/src/permission/arity.ts:1`）

`ARITY` 是一张一百三十多条的字典，注释说明它由一段提示词生成，规则是最长前缀优先：`git` 为 2，`npm run` 与 `docker compose` 为 3，`cat` 为 1。于是：

| 命令 | 记住的 always 模式 |
| --- | --- |
| `git checkout main` | `git checkout *` |
| `npm run dev` | `npm run dev *` |
| `docker compose up -d` | `docker compose up *` |
| `./build.sh --fast` | `./build.sh *`（不在字典里，只取第一个词） |

用户在授权框里选“总是允许”时存下的正是这些模式；而通配符匹配会让结尾的“空格加星号”变成可选，所以 `git checkout *` 也覆盖不带参数的 `git checkout`。

下一篇：[子代理、待办与提问](https://daiw.org/manual/opencode/task-and-todo)——task 如何派生子会话、结果怎么回传，以及 todowrite、question、plan_exit 与 skill。
