# Git 隔离与安全护栏

> 工作区信任、隔离子会话的 git 拦截、主工作树与合并冲突提示、删除二次确认、先读后改、SSRF 防护，以及权限求值顺序与询问超时。

- 作者：David（道雾轩）
- 专栏：MiMo Code 源码解读（https://daiw.net/manual/mimo-code.md）
- 最后更新：2026-09-17
- 原文：https://daiw.net/manual/mimo-code/git-guards
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

MiMo Code 常让多个 agent 同时动一个仓库：Actor 子智能体、Orchestrator 用 `--isolate` 派出的子会话、Workflow 给子任务开的 worktree。本篇把分散在各处的护栏放在一起讲。它们大多是“把关”或“提示”，不是沙箱，源码注释对此也写得很直白。上游的权限模型见 [权限系统](https://daiw.net/manual/opencode/permission)，本篇只讲 MiMo 的改动。

## 怎么用

| 开关 | 位置 | 作用 |
| --- | --- | --- |
| `--trust` | `mimo` 命令行 | 跳过工作区信任提示 |
| `auto_worktree` | 配置 | 设为 `true` 时，主会话第一次改动 git 主工作树后注入一次提醒，默认关闭 |
| `/permission-timeout` | TUI | 权限询问超时：不超时、30 秒、1、2、5、10 分钟 |
| `MIMOCODE_SKIP_ALL_FORCED_ASK_TIMEOUT_MS` | 环境变量 | 上面那个超时的初始毫秒数，沿用的旧名字 |
| `/skip-permissions` | TUI | 运行期自动放行普通询问，删除确认除外 |
| `MIMOCODE_AUTO_APPROVE_DELETE` | 环境变量 | 设为 `true` 后不再单独确认删除类命令 |
| `--dangerously-skip-permissions`（别名 `--yolo`） | 命令行 | TUI 在用户配置之下垫一条全放行规则，`mimo run` 则自动回复每个询问；两者都放行删除确认，显式 deny 仍然生效。TUI 在交互终端里会先要求确认 |
| 权限键 `bash_delete` | 配置 `permission` | 只有 deny 有效，allow 放不过删除确认 |

一条 bash 命令会依次经过下面几道关卡：

```mermaid
flowchart TD
  A["bash 命令"] --> B["tree-sitter 解析出每个命令节点"]
  B --> C{"会话目录是隔离 worktree 且跨了分支？"}
  C -- "是" --> X["抛错，不询问也不执行"]
  C -- "否" --> D{"含删除类命令？"}
  D -- "是，且不全在临时目录" --> E["bash_delete 强制询问"]
  D -- "否，或全在临时目录" --> F["普通 bash 权限询问"]
  E --> G["执行"]
  F --> G
  G --> H["探测合并冲突，必要时追加归属说明"]
  H --> I["元数据记下命中的主工作树"]
  I --> J["下一步：auto_worktree 开启时注入一次提醒"]
```

## 工作区信任

`mimo` 启动 TUI 前检查当前目录（`packages/opencode/src/cli/cmd/tui/thread.ts:240`）。`checkTrust`（`packages/opencode/src/project/workspace-trust.ts:23`）分三档：目录正好是主目录（依次比较 `HOME`、`USERPROFILE`、`HOMEDRIVE` 拼 `HOMEPATH` 与 `os.homedir()`）或文件系统根时为 `dangerous`；等于或位于 `<data>/trusted-workspaces.json` 所记路径之下时为 `trusted`；其余为 `untrusted`。

不是 `trusted` 就弹出选择框，取消或选否直接退出。`untrusted` 目录选择信任后写入列表（带文件锁），`dangerous` 目录每次都要确认，不会被记住。提示里有一句专门讲插件风险：

> If malicious plugins exist in this directory, they can execute arbitrary code, read, modify, or exfiltrate your files.

（`packages/opencode/src/cli/cmd/tui/i18n/en.ts:585`）

`--trust` 跳过整个检查（`thread.ts:199`）。代码里只有 TUI 入口做这项检查，`mimo run` 不做。

## 隔离子会话：拦下跨分支的 git 操作

应用管理的 worktree 都建在 `<data>/worktree/<项目 ID>/` 下（`packages/opencode/src/worktree/index.ts:239`）。同一仓库的所有 worktree 共用一个引用存储，子会话在自己的目录里执行 `git update-ref refs/heads/main HEAD`，一样能挪动主检出正在用的分支。`packages/opencode/src/tool/isolated-git-guard.ts` 的文件头注释记下了实测结果，并说明它要把 orchestrator 提示词里的叮嘱变成机制。

bash 工具在权限询问之前调用它（`packages/opencode/src/tool/bash.ts:1282`）：只要会话自己的目录位于 `<data>/worktree` 之下，就检查 tree-sitter 解析出的每个命令节点，管道、`&&` 链与子 shell 里的 git 命令都不例外。依据的是会话目录而不是命令的 `cwd`，先 `cd` 进主检出再 rebase 同样会被拦。`violates`（`isolated-git-guard.ts:158`）先跳过 `-C`、`-c`、`--git-dir` 等全局选项找出子命令；下表的“自己的分支”指从 worktree 的 `.git` 文件读出的当前分支，读不到时一律按“不拥有”处理：

| 子命令 | 被拦的情形 |
| --- | --- |
| `rebase`、`merge` | 除 `--abort`、`--continue`、`--skip`、`--quit` 等处理进行中操作的旗标外一律拦 |
| `checkout`、`switch` | 切到别的分支、`--detach`、用 `-B` 或 `-C` 强建别人的分支；带 `--` 或多个位置参数（恢复文件）、目标是路径、新建分支都放行 |
| `branch` | 带删除、强制移动或改名旗标，且对象不全是自己的分支 |
| `push` | 强推或删除，且目标不是自己的分支；不写 refspec 视为推自己的分支，普通推送不拦 |
| `worktree` | `add`、`remove`、`move`、`prune`、`repair`、`lock`、`unlock` |
| `update-ref` | 目标不是自己的分支 |
| `symbolic-ref` | 两个位置参数（写入）或删除 |
| `tag` | `-f`、`-d` 及其长写法；新建标签放行 |

`reset` 与 `pull` 故意不拦，它们只动当前 worktree 自己的分支；`reset --hard` 另有下文的删除确认。命中时直接抛错，信息里写明这个 worktree 与分支，要求只提交、推送自己的分支，把 rebase、merge 交给 orchestrator。注释也交代了能力边界（`isolated-git-guard.ts:46`）：

> This is a string/argv-level check over the already-parsed command AST. A determined command can evade it (shell aliases, `sh -c "$(printf …)"`, `eval`, a wrapper script, a git alias configured in the repo). It is not a sandbox and does not try to be one.

## 主工作树：只提示，不拦截

bash 工具解析命令时还算出两样东西（`bash.ts:1272`）：

- `isFileWrite`（`bash.ts:293`）：有写文件的重定向（`2>&1` 这类描述符复制不算），或命令头是 `tee`、`cp`、`mv`、`mkdir`、带 `-i` 的 `sed`、PowerShell 的 `set-content` 等。
- `mainWorktreeHits`（`bash.ts:519`）：跟踪 `cd`、`pushd`、`popd` 推算实际目录，收集重定向目标、写命令的路径参数（`cp`、`ln`、`install` 只算目标）、`git -C` 指向的目录，以及会改工作树的 git 子命令，再逐级向上找 `.git` 为目录的主工作树。

结果写进工具元数据。配置 `auto_worktree` 为 `true` 时（`packages/opencode/src/config/config.ts:126`），`insertReminders` 在主 agent 的根会话里扫描历史（`packages/opencode/src/session/prompt.ts:1189`）：`write`、`edit`、`apply_patch`、`multiedit`、`notebook_edit` 成功写入的文件，或退出码为 0 的 bash 调用记下的命中，只要落在某个 git 主工作树里（`packages/opencode/src/tool/auto-worktree-hint.ts:118`），就在 user 消息上注入一段 `Auto-Worktree Notice` 提醒，并把会话的 `auto_worktree_hint_sent` 置位，每个会话只提醒一次。文案分两种（`auto-worktree-hint.ts:190`）：仓库 `.git/worktrees` 下已有链接工作树，就让模型直接新建 worktree 切过去、不必先问；否则明确要求不要自作主张，先问用户要不要隔离。

同目录下的 `conflict-detection.ts` 是另一半。`checkConflict`（`packages/opencode/src/tool/conflict-detection.ts:91`）依次看三个信号：同目录有 5 分钟内更新过且未归档的会话；`.git/index.lock` 存在；有名为 `claude`、`codex` 或 `cursor` 的进程（Linux、macOS 上用 `lsof` 加 `ps` 查打开了该目录文件的进程，Windows 上用 `wmic` 只比进程名，命令不可用时视为无冲突）。它只被实验接口 `POST /experimental/worktree/auto` 调用，有冲突就新建 worktree（`packages/opencode/src/server/routes/instance/experimental.ts:359`），仓库里没有客户端调用这个接口。

对照设计文档 `docs/compose/specs/auto-worktree.md`：文档设想在创建会话时检测冲突、自动建 worktree，由 write、edit、apply_patch 工具各自注入提示，外部进程名单还有 Kilo Code；代码里会话创建路由没有接入冲突检测，提示统一在 `insertReminders` 注入，进程名单只有三项。`docs/compose/spec/auto-worktree-config-toggle.md` 描述的默认关闭与作用范围与代码一致。

## 合并冲突：说明冲突归谁

`packages/opencode/src/tool/merge-conflict-notice.ts` 的长注释交代了起因：orchestrator 提示词已经写明冲突应交还分支的主人，可在 mimo-v2.5 上 3 次实测都没照做，模型合并出冲突后自己读文件、改冲突标记、提交，有一次还删掉了作者的分支。注释认为系统提示词离当前决策太远，而工具结果在下一次工具调用之前必然被读到，于是改为在产生冲突的那次 bash 结果末尾追加说明（`bash.ts:1218`）。

`annotate`（`merge-conflict-notice.ts:204`）分两层判断。先做便宜的文本测试，决定值不值得起 git 进程：输出里有大写的 `CONFLICT`，或命令里 `git` 后面跟着 merge、rebase、cherry-pick、revert、pull、am 之一（`merge-conflict-notice.ts:132`）。再要求两个事实同时成立：`git ls-files --unmerged` 有输出，且 git 目录里存在 `MERGE_HEAD`、`CHERRY_PICK_HEAD`、`REVERT_HEAD`、`rebase-merge`、`rebase-apply` 之一。退出码不参与判断，`git merge x || true` 照样识别；只是打印了 CONFLICT 字样的 `echo` 则会在第二层被排除。

追加的文字列出未合并的文件、对应的中止命令（如 `git merge --abort`、`git rebase --abort`）和一条 `session send <owning-session-id> "..."` 模板，并声明这是内部上下文、不要复述给用户。这项检查对所有会话生效；隔离子会话里的 `git merge` 已被上一节的护栏拦下，两者不会打架。

## worktree 服务的改动

与 v1.14.19 的 `packages/opencode/src/worktree/index.ts` 相比：

- 分支前缀从 `opencode/` 改为 `mimocode/`（`worktree/index.ts:219`）。
- 按父仓库加信号量（`worktree/index.ts:187`），避免并发的 `git worktree add` 争用 `.git/index.lock`。
- 原先先 `worktree add --no-checkout -b`、启动时再 `reset --hard`，现在一步 `worktree add -b` 直接检出，随后用 `symbolic-ref` 断言 HEAD 挂在新分支上（`worktree/index.ts:253`）。
- 把父仓库解析出的 `user.name`、`user.email` 写进新 worktree 的本地配置（`worktree/index.ts:295`）；bash 工具也会在项目仓库能解析出身份时补上 `GIT_AUTHOR_NAME`、`GIT_AUTHOR_EMAIL`、`GIT_COMMITTER_NAME`、`GIT_COMMITTER_EMAIL`，不覆盖用户已经设置的值（`bash.ts:979`）。
- 删除 worktree 前先销毁该目录上的实例（`worktree/index.ts:439`）；新增 `head` 与 `isPristine`（`worktree/index.ts:648`），Workflow 运行时据此删掉没有产生改动的 worktree（`packages/opencode/src/workflow/runtime.ts:1027`，见 [Workflow 运行时](https://daiw.net/manual/mimo-code/workflow-runtime)）。

## 删除二次确认

bash 工具把删除类命令单独拎出来（`bash.ts:117`、`bash.ts:135`）：`rm`、`rmdir`、`unlink`、`shred`，PowerShell 的 `remove-item`、`del`、`rd` 等，以及 `git reset --hard`、`git clean -f`、`git branch -D`、`git tag -d`、`git worktree remove`、`git push --force`、`git stash drop` 或 `clear`。命中时发起权限名为 `bash_delete` 的询问，`always` 为空，并代替普通的 bash 询问（`bash.ts:748`）。

`bash_delete` 在权限服务里属于 `FORCED_ASK`（`packages/opencode/src/permission/index.ts:222`）：配置里的 allow 规则和“总是允许”的记录都放不过它，只有显式 deny 能提前拒绝；即使回复“总是允许”也按一次处理（`permission/index.ts:573`）；`/skip-permissions` 同样不放行。例外有三种：

- 每个删除目标都能解析出来，且都在系统临时目录之内（不含临时目录本身，也不含项目内的路径）时，不做二次确认，只走普通询问（`bash.ts:917`）。带通配符、变量或命令替换的参数一律算无法确认；git 类删除操作的是仓库状态，从不享受这个例外。
- `MIMOCODE_AUTO_APPROVE_DELETE=true` 或 TUI 以 `--dangerously-skip-permissions` 启动时，实例的 `autoApproveDelete` 默认打开，`bash_delete` 自动通过，但还要再过一次普通 bash 询问（`permission/index.ts:240`、`bash.ts:1308`）。
- `mimo run --dangerously-skip-permissions` 在运行期间通过接口临时打开这个开关，结束后复原（`packages/opencode/src/cli/cmd/run.ts:38`）。

识别也有漏网的写法：`isDelete` 直接把第二个词当作 git 子命令（`bash.ts:547`），`git -C 目录 reset --hard` 这种带全局选项的形式不会触发删除确认。

## 先读后改与可恢复错误

`edit`（替换已有内容时）与 `notebook_edit` 要求目标文件在对话里被 `read` 工具成功读过，否则抛出 `RecoverableError`，让模型先读再重试（`packages/opencode/src/tool/read-state.ts:26`、`packages/opencode/src/tool/edit.ts:87`）。路径相对会话工作目录解析，Windows 上不区分大小写。检查的是本轮请求实际带着的历史，压缩边界之前的读取不算数；`multiedit` 内部调用 `edit`，一并受约束；`write` 与 `apply_patch` 不检查。v1.14.19 只在 `edit.txt` 里写了“编辑前必须先读”的用法说明，代码并不强制。

`RecoverableError`（`packages/opencode/src/tool/recoverable.ts:15`）标记模型下一轮就能自己纠正的失败：参数校验不通过（工具包装层把 zod 错误统一转成它，`packages/opencode/src/tool/tool.ts:121`）、未读先改、MCP 工具本轮不可用、任务或 actor ID 不存在等。处理器给失败的工具部件加上 `metadata.recoverable`（`packages/opencode/src/session/processor.ts:381`），TUI 看到这个标记就把该行画成删除线，不显示红色错误块（`packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:3089`）；交给模型的错误文本不变。判断时先看类型，再退而检查结构上的 `recoverable === true`，免得经过 `orDie` 和序列化后 `instanceof` 失效（`recoverable.ts:32`）。

## webfetch 的 SSRF 防护

v1.14.19 的 `webfetch` 不校验目标地址。MiMo 在询问权限之前调用 `assertSafeUrl`（`packages/opencode/src/tool/webfetch.ts:38`，实现在 `packages/opencode/src/util/ssrf.ts:83`）：

- 拒绝主机名 `metadata.google.internal`、`metadata.goog`、`kubernetes.default.svc`；
- IPv4 拒绝以 `10.`、`0.` 开头的地址，以及 172.16 至 172.31、192.168、169.254 链路本地、100.64 至 100.127 共享地址段，外加阿里云元数据地址 100.100.100.200；
- IPv6 拒绝 `fe80:` 链路本地、`fc` 与 `fd` 开头的唯一本地地址，以及映射到上述 IPv4 的地址；
- 域名先做一次 DNS 解析再检查结果，解析失败也按拒绝处理。

响应回来后，若最终 URL 与请求 URL 不同，再校验一次（`webfetch.ts:99`）。几处边界：回环地址 127.0.0.0/8、`localhost` 与 `::1` 不在拒绝范围内；DNS 检查用的是 `lookup` 返回的一个地址，真正发请求时 HTTP 客户端会重新解析；重定向由 HTTP 客户端自动跟随，事后校验只能拦住内容，请求已经发出。同文件里逐跳手动校验重定向的 `safeFetch`（`ssrf.ts:58`）在 `src` 里没有调用方。

## 权限：求值顺序、跳过与超时

`Permission.ask`（`permission/index.ts:257`）在上游基础上有这些改动：

1. **deny 不可被覆盖**：v1.14.19 把规则集和运行期批准记录拼在一起取最后一条匹配，“总是允许”的记录可以压过配置里的 deny；MiMo 先单独用规则集求值，命中 deny 立即失败，批准记录只能把 ask 升级为 allow（`permission/index.ts:272`）。
2. **强制询问**：即上一节的 `bash_delete`。
3. **运行期跳过**：`/skip-permissions` 打开实例级的 `skipAll`，此后会阻塞的普通询问直接放行；打开的同时还会放行已经挂起的普通询问（`permission/index.ts:310`、`permission/index.ts:618`）。
4. **非交互调用**：系统派生的 agent 以 `interactive: false` 询问，需要人工确认时直接以 `DeniedError` 失败，不会挂起（`permission/index.ts:370`）。后台子智能体继承父会话已有授权、Orchestrator 子会话的审批转发，分别见 [Actor](https://daiw.net/manual/mimo-code/actors) 与 [Orchestrator](https://daiw.net/manual/mimo-code/orchestrator)。

第 5 处是询问超时（`packages/opencode/src/permission/index.ts:503`）：

```ts
      const timeoutMs = s.permissionAskTimeoutMs
      if (timeoutMs != null) {
        guarded = Effect.timeoutOrElse(guarded, {
          duration: `${timeoutMs} millis`,
          orElse: () =>
            bus
              .publish(Event.Replied, {
                sessionID: info.sessionID,
                requestID: info.id,
                reply: "reject",
              })
              .pipe(
                Effect.andThen(() =>
                  Effect.fail(
                    new CorrectedError({
                      feedback: `No user response within ${Math.round(timeoutMs / 1000)}s. This action was auto-rejected as a safety measure — NOT an explicit user denial. Skip this operation and continue with the rest of the task.`,
                    }),
                  ),
                ),
              ),
        })
      }
```

超时默认为 `null`，即一直等。设置途径有三：TUI 的 `/permission-timeout` 选择框，档位写在 `packages/opencode/src/cli/cmd/tui/component/dialog-permission-timeout.tsx:8`；接口 `GET` 与 `POST /permission/ask-timeout`（`packages/opencode/src/server/routes/instance/permission.ts:176`）；旧环境变量 `MIMOCODE_SKIP_ALL_FORCED_ASK_TIMEOUT_MS` 给出新实例的初始值（`permission/index.ts:30`）。它对普通询问和强制询问都生效，与 `skipAll` 互不影响。超时失败用的是 `CorrectedError`：处理器只在 `RejectedError` 时把本轮标记为受阻（`processor.ts:403`），所以模型拿到反馈后会继续，反馈里也写明这不是用户的拒绝。

设计文档 `docs/compose/spec/permission-skip-and-timeout-decouple.md` 与代码基本一致，只是把接口写成了 `/instance/permission/ask-timeout`，实际路由挂在 `/permission` 下（`packages/opencode/src/server/routes/instance/index.ts:61`）。

下一篇：[MCP：采样与工具检索](https://daiw.net/manual/mimo-code/mcp)——MCP 服务器反过来请模型补全时怎么选模型、怎么审批，以及工具太多时的按需检索。
