# 设计决策与取舍

这份文档记录 `git-for-dsh` **为什么这样设计**：每个机制换来什么、代价是什么、边界在哪里。

- [README.md](README.md)：这个插件是什么、怎么装、怎么用、设置项是什么意思。
- [PITFALLS.md](PITFALLS.md)：开发过程中实际踩过的坑，每条都写「现象 / 根因 / 怎么判 / 怎么避 / 谁守着」。
- 这份文档：维护者改动之前需要知道的**背景与权衡**。这里的内容不是「坑」，而是**决定** —— 想改之前先看清它承着什么。

---

## 一、总体定位与边界

### 决策：显式不设沙箱，用允许清单作为安全边界

会话沙箱的 workspace root 是**进程 cwd**，而 git 会写 `.git`、对象包、以及仓库外的配置文件；在受限沙箱里跑仓库操作会以难以理解的方式失败。因此插件的 git 调用**显式指定在沙箱外执行**，改用允许清单作为边界：只有用户勾选过的子命令能启动进程，参数还要过参数闸门。

- 换来的是：仓库操作可用，且能力边界由用户自己勾选、随时可收回。
- 代价是：**这个 git 进程本身不受沙箱保护**。安全性完全取决于闸门是否正确 —— 闸门有漏洞就等于越权。因此清单默认只开只读档，写档与远程档都需要用户显式开启。

### 决策：不改 dsh 源码、不动沙箱策略

插件能给出的硬保证上限是「沙箱内遮蔽这些文件」；那属于 dsh 沙箱的职责。插件不去改 dsh 源码、也不替部署方修改沙箱策略，只如实标注自身强度（见 README「工具守卫 / 如实说明它的强度」）。代价是：无法阻止模型用 `bash` 绕过本插件直接驱动 git，也无法阻止它读同 uid 可读的凭据文件。

### 决策：闸门分层，每层只管一段

| 层 | 回答的问题 | 位置 |
| --- | --- | --- |
| 允许清单 | 这个子命令能不能跑 | `src/git-catalog.js` 的目录 + Host 的设置 |
| 参数闸门 | 这次调用是怎么跑的 | 同上（`validateArgv` / `hardenArgv`） |
| 命令级加固 + 仓库配置审计 | 跑起来之后按什么配置跑 | `buildEnv()` 的环境注入 + 每次调用前的审计 |
| 逐次审批 | 用户是否同意这一次 | Host 半的 `approveMutating` 分支 |

分层的目的不是叠加同样的事，而是**让绕过一个层不能直接变成绕过全部**：例如审计默认一票拒绝，而命令级加固的钉死项与审计无关，是「不管配置怎么来的都生效」。

### 决策：Host 半做判定，Client 半只做界面

判定只有一处实现（Host），界面只有一处实现（Client）。两半共享同一个设置命名空间 `git-tool`，共享同一份操作目录。代价是两侧都要维护一致的选项集，因此有一条硬纪律：**同一份选项集不维护两份**，并且用往返测试防漂移（见 PITFALLS 30）。

### 决策：两半都要能失败而不带走整个页面

`apply()` 初始化失败会让设置面板不可用（这是真实发生过的事故，见 PITFALLS 1），所以：

- Host 半把整段初始化包在 try/catch 里：失败只留一行 `git-tool: activation failed …`，工具不注册，会话照常可用；
- Client 半导出 `inject: ['slots', 'settingsScope']` 让激活等待服务就绪（**这是顺序保证的唯一来源**），服务确实缺失时降级成不可写页面，整个工厂体另有 try/catch，求值失败就交出空操作插件；页面本身还套 React error boundary。

代价：失败被吞掉时看起来像「功能没提供」。因此这些兜底都配了可见性 —— 日志里的 `activation failed` 一行、以及设置页上的状态行。

---

## 二、工具与闸门

### 决策：档位（tier）与形式（form）是两个维度

允许清单按操作名分档，但一个操作的只读性取决于它的**形式**：`branch` 是只读，`branch <名>` 是创建引用。所以目录里除了档位，还为这类操作声明「形式规则」，并按性质分两种处理：

| 变更形式 | 碰什么 | 处理 | 理由 |
| --- | --- | --- | --- |
| `branch <名>` / `tag <名>` / `remote prune` | 引用、网络 | 写档 + 审批 | 保留能力，与其它改状态的操作走同一道闸门 |
| `remote add/remove/rename/set-url` | `.git/config` | 直接拒绝 | 与 `config` 写入同类 |

代价：形式规则是白名单枚举（见 README 的表格），新增 git 子命令或新旗标时需要同步维护；漏一个写形式就等于给只读档开了一个写入口。

### 决策：参数闸门只在「全局位置」拒绝危险选项

`-c` / `-C` / `--git-dir` / `--work-tree` 等只在**第一个参数**位置被拒绝。依据是 git 自身的行为：这些全局选项在子命令之后不再被采纳，而在那里它们往往是子命令自己的旗标（`switch -c`、`commit -C`、`add -u`、`init --bare`）；一律拒绝会误伤日常操作，且不提供额外保护。

- 换来的是：正常的日常命令不用绕路。
- 代价是：规则依赖「第一个参数必须是目录里的子命令」这条前置约束，也依赖 git 对全局选项位置的解析方式。**升级 git 大版本时应重新核验**这些位置语义。
- 与之相对，`--upload-pack` / `--receive-pack` / `--exec` 因为指定「git 要执行的程序」，在任何位置都拒绝；`-u` 的判定跟随子命令。

### 决策：操作数规则写进目录，而不是写进闸门的判断逻辑

每个操作自己声明是否接受尾部操作数（`positional`）、是否接受文件路径（`filePaths`）；两者都没声明的（`count-objects`、`ls-files`、`ls-tree`、`for-each-ref`、`name-rev`）只允许带选项的形式。

- 换来的是：闸门不需要知道每个子命令的位置参数语义，也不会出现「某操作悄悄接受了本该被拦的路径」。
- 代价是：这些操作无法通过 `paths` 参数传入路径；需要路径时必须换用接受路径的操作。

### 决策：能被钉死的用「钉死」，钉不死的才用「审计」

仓库配置里能指定程序或重定向命令，而只读操作也会读配置。有对应单个环境键的（`core.fsmonitor`、`core.gitProxy`、`core.pager`、`core.hooksPath`、`credential.helper`）直接在环境通道钉死，**优先级高于任何配置文件**；git 原生通配的（`filter.<driver>.clean`、`url.<base>.insteadOf`、`alias.<name>`、`merge.<driver>.driver`）没有单个键可覆盖，只能运行前审计后拒绝。

- 换来的是：钉死的部分是「不管配置怎么来的都生效」，即使模型直接改写 `.git/config` 也绕不过（配置审计则做不到这一点，因为仓库就在工作区内、模型可以直接写）。
- 代价是：审计必须每次调用都了解仓库配置，因而有一次额外的 git 进程与缓存（见第六节）；审计的默认裁定是**一票拒绝**，含此类键的仓库会完全不可用 —— 因此提供 `refuse-affected` 与 `neutralize` 两档，以及明确标注会失去保护的 `off`。

### 决策：写 `.git/config` 的形式一律拒绝，而不是走审批

`config <键> <值>`、`--unset`、`-f`，以及 `remote add/remove/rename/set-url` 都直接拒绝。原因是审批提示里只能显示命令，显示不出**影响面**：`remote set-url` 静默改变后续 push 的目的地，而需要审批的 push 提示里只有命令，没有 URL；`config -f` 可以定向读仓库外的文件。

- 代价是：git 自己给出的建议（`git config pull.rebase false`）不可执行，必须换算成等效旗标（`git pull --rebase`），README 里列了对应关系。

### 决策：审批与清单是两层，不互相替代

清单回答「这类操作允许过吗」，审批回答「这一次同意吗」。写档与远程档勾选之后仍然每次弹审批（`approveMutating`），因为一次误操作与「这类操作整体开放」是两件事。

代价：交互上更啰嗦，而且审批策略与沙箱模式被 `dsh-permission-presets` 绑在同一条预设里（「完全权限」预设会同时把审批策略设成 `never`），因此用户在切换预设时会连带改变审批行为，见 PITFALLS 12。插件对此的处理是：两侧都给出可关的开关和明确的解释文案，而不是假设用户知道这条耦合。

---

## 三、执行环境与凭据

### 决策：非交互环境全部通过环境变量固定，不用 `-c`

`buildEnv()` 固定 `GIT_TERMINAL_PROMPT=0`、`GIT_ASKPASS=''`、分页器、编辑器、`core.hooksPath=` 平台空设备（Windows 上为 `NUL`，其余为 `/dev/null`），并把需要的配置通过 `GIT_CONFIG_COUNT` / `GIT_CONFIG_KEY_n` / `GIT_CONFIG_VALUE_n` 注入子进程。

- 换来的是：**同一个注入通道不能由调用方使用** —— `-c` 与 `--config-env` 可以留在拒绝名单里，因为插件自己不需要它们。
- 代价是：注入的配置必须在代码里逐个列出，新增一项要同时考虑它与拒绝名单的关系。

### 决策：显式沙箱外执行，并自己解析工作目录

每次调用传 `sandboxPolicy: { mode: 'danger-full-access', workspaceRoot }`，同时 **Host 自己**把 workdir 解析成会话工作区，而不是使用 shell 的默认值（后者的 workspace root 是进程 cwd）。

代价与第一节同源：这个进程不受沙箱约束，约束来自闸门；另外 `workspaceRoot` 必须随会话传入，缺会话时才有回落逻辑。

### 决策：默认隐藏用户级与系统配置，并钉空 `credential.helper`

默认把 `GIT_CONFIG_GLOBAL` / `GIT_CONFIG_SYSTEM` 指向平台空设备（Windows 上为 `NUL`），并设 `GIT_CONFIG_NOSYSTEM=1`，`credential.helper` 钉成空字符串。

- 换来的是：一次性压住全局配置里所有「会执行程序」和「会重定向主机」的键（`alias.*`、`url.insteadOf`、helper 程序）。
- 代价是：**远程写操作一律失败**（`could not read Username`），这是设计而非故障；需要认证能力时必须显式开启下一项。

### 决策：保留「允许 git 使用本机凭据」开关，默认关闭

开启后，`~/.gitconfig` 与系统配置恢复生效，`credential.helper` 不再钉空（其它钉死项**保持不变**）。这样 git 自己读凭据，令牌不经过 argv、审批提示或会话记录。

- 换来的是：既能推送，又不把密钥放进会话记录。
- 代价是：全局配置里的一切也一并回来 —— `url.<base>.insteadOf` 可以把凭据送到非预期的服务器，`credential.helper` 与 `alias.*` 是 git 会执行的程序。因此开关旁边直接写明代价，并注明只应在信任该机器全局配置时开启。
- 这一项**不解决**「令牌会不会被 AI 读到」：凭据文件对同 uid 可读，dsh 的文件策略限制的是写入而不是读取（见 PITFALLS 13、24）。把它当安全边界用是方向性错误。

### 决策：SSH 程序由 `GIT_SSH_COMMAND` 钉死，并配一个探测按钮

先前把 `core.sshCommand` 钉成 `false`（谁都不能指定 ssh 程序）的写法让 SSH 传输完全不可用，只提供 SSH 远端的仓库无路可走。现在改为在环境里设 `GIT_SSH_COMMAND=<程序> -o BatchMode=yes -o StrictHostKeyChecking=accept-new`：环境通道同样压过一切配置文件，所以仓库依旧无法指定程序，但 SSH 可用。

- 代价一：程序路径写在设置里，填错等于 SSH 不可用 —— 而不同发行版、Windows 互操作下它的位置并不统一，因此设置页提供「探测」按钮（扫描 `$PATH` 与常见位置、验证可执行并取版本号，点击时运行，不在热路径上做文件系统访问）。
- 代价二：`accept-new` 表示首次连接自动信任主机键。这是无交互环境里唯一能走通的取舍（否则首次连接必然要人工确认）。

### 决策：不管理凭据，也不承诺「AI 读不到凭据」

插件只承诺「自己不读取、不传递、不存储凭据，也不成为泄漏路径」：`credential.helper` 钉空、`git config -f <任意文件>` 被拒、调用方无法用 `-c` 塞配置、子进程环境由 harness 擦除敏感变量名。至于密钥由哪个 uid 持有、要不要放进沙箱遮蔽的路径，属于部署方的决定。远程认证推荐走**外部代理**（`HTTPS_PROXY` 由 git 继承，`scrubbedParentEnv()` 明确保留代理变量）。

---

## 四、工具守卫

### 决策：用 `tools/pre-execute` 事件，而不是守卫服务

动态插件拿到的是受限服务表面，`ctx.tools.guard` 不可用；`tools/pre-execute` 是 waterfall 事件，`ctx.on` 就能监听，决定类型是 `allow` / `deny` / `ask`（见 PITFALLS 25）。

### 决策：只做放行 / 拒绝 / 询问

工具管线**不能改写参数**，所以「拦截 bash 里的 git 调用并把它路由到 `git_exec`」在工具层做不到。三个诚实选项（外加一个可选的 `ask`）就是能做到的全部：拒绝时在原因里指明改用 `git_exec`。

### 决策：默认 `deny`（宽匹配），把 `restrict` 作为可选项

两档「拒绝」的差别只在**判定宽度**：宽匹配对内容里出现 git 的任何调用都拒（含「只是提到」），`restrict` 只在命令位置判（不误伤，但可能漏掉生僻写法）。默认宽匹配，因为漏掉一次真实调用比误拒一次更糟；同时也把 `restrict` 明确提供给「命令里经常出现 git 字样」的使用者，因为那是真实且常见的需求（内容级检查的必然代价见 PITFALLS 34）。

### 决策：不做「由本插件自己调 Git Bash」这个选项

社区反馈"DSH 自带 bash 工具在 Windows 上不可用"，据此有人建议让插件自己拉起 Git Bash。核查后**不采纳**，理由写在下面，并附上核实到的边界 —— 将来若再有人提同一件事，从这里开始。

**核实到的事实**（对着 dsh 源码逐条查）：

- 两个相关包**确实存在**：`dsh-subprocess-local`、`dsh-sandbox-windows-acl`（另有 `dsh-bash-sandbox`、`dsh-pwsh-sandbox` 等并列实现）。
- 两句报错原文**确实存在**：`terminal inspection is unsupported on platform win32` 在 `dsh-subprocess-local` 的 runner 里，`PTY shell exited during startup` 在 `dsh-terminal-bash` 里。也就是说，出问题的是**交互式 PTY 终端**与**进程检查器**。
- `MSYSTEM` / `CHERE_INVOKING` 在整个代码树里**搜不到** —— 那是"谁自己手动起 Git Bash 就得自己设"的注意事项，不是 dsh 的缺陷。
- **受限令牌这一条前提成立**：`dsh-sandbox-windows-acl` 的 FFI 绑定表里直接列出 `createRestrictedToken(… restrictingSids …)` 与 `setEntriesInAclW` / `setNamedSecurityInfoW` 等调用，其文档也以"受限令牌进程隔离"自述 —— 沙箱确实在带 restricting SID 的受限令牌下启动被隔离进程，而这正是会打断 Cygwin/MSYS 创建共享内存映射的那类隔离。**仍无法在此验证的**是"MSYS 在该令牌下必然崩溃"这一步（需在真机把 Git Bash 放进该沙箱运行）。

**为什么不采纳**：

1. **我们走的不是那两个坏掉的路径。** 本插件用 `ctx.shell.run` 执行**一次性命令**，不使用交互式 PTY，也不依赖终端检查器。这一点有直接证据：在 Windows 上通过本工具执行的 git **确实跑起来了**并返回了 git 自己关于 `ssh.exe` 的报错，说明这条路径是通的。
2. **代价落在错的地方。** 自己拉起 Git Bash 等于**绕开 harness 的 shell 服务**，随之失去超时、输出上限、信号处理、沙箱策略接线与结果封装。
3. **收益未测量。** 目前没有任何数据表明它更快。

**若将来真的需要备选，方向不是「换哪个 bash」，而是「不用 shell」**：git 接受 argv，本插件的加固流程也已经在产出 argv，去掉 shell 这一层既更快，也免疫 shell 的解析怪癖 —— 我们刚修完的"反斜杠被 shell 吃掉导致 `command not found`"正是这一类。



`workdir` 由调用参数提供，而在加这个闸门之前它**没有任何约束**：配合 `danger-full-access`（本工具刻意在沙箱外执行），模型可以在机器上任何目录运行 git —— 包括用户不希望它接触的仓库。这不是漏洞，而是一个没人明确做过的决定；现在它变成三个明确档位，默认取最保守的一档。

**为什么默认「仅工作区」而不是沿用旧行为**：放开范围应当是一个需要读懂的显式选择，而不是没人注意的默认值。

三条比较规则各防一类失效：

1. **按路径段比较**，不用字符串前缀 —— 否则 `/work/app2` 会被当作在 `/work/app` 之内；
2. **先解析软链接** —— 否则工作区内一个指向 `/` 的链接就能把范围整个绕过；
3. **Windows 上忽略大小写** —— 因为它的文件系统忽略，否则会出现"填了 `C:\Work` 却拒绝 `c:\work`"的自相矛盾。

**闸门判定的是「生效目标」，不是「调用写了什么」**：省略 workdir 时工具会在上游把它默认成会话工作区，因此按原生参数判定会拒绝合法调用 —— 这是实现过程中出现、并被测试抓住的一个真实缺陷。

**空配置即拒绝**：选了「指定路径」却一个根目录都没填时，拒绝而不是放行，否则最严格的档位会变成最宽松的。

### 决策：ssh 程序路径走 `GIT_SSH_COMMAND`，用正斜杠并始终加引号

`GIT_SSH_COMMAND` **会经 shell 解析**（Git for Windows 用 bundle 里的 `sh.exe`），所以路径必须能活着穿过一层 shell。两条防线，各自覆盖不同的失败：

1. **Windows 路径转正斜杠** —— Windows 全线接受正斜杠，而没有任何 shell 对它另有解释，因此路径能穿过任何解析器。这不是理论问题：未加引号时，`C:\Windows\System32\OpenSSH\ssh.exe` 的每个反斜杠都被 shell 当作转义吃掉，git 收到 `C:WindowsSystem32OpenSSHssh.exe` 并报 `command not found`。
2. **始终加引号** —— POSIX 双引号内反斜杠是字面量，因此这一步既保护反斜杠，也保护含空格的路径（macOS 的 `/Applications/…`、Windows 的 `Program Files\…`）。早先只在"含空格"时加引号，正是这个路径没能被保护到的原因。

**判断依据是路径的形态（`C:` 开头），而不是宿主平台**：这样在 Linux 上构建时遇到 Windows 路径同样会被修正，规则也才可以在任何平台上被测试。

**为什么不改用 `GIT_SSH` + `GIT_SSH_VARIANT=ssh`**：`GIT_SSH` 是纯路径、不经 shell，看起来更简单，但它**不允许带参数**，我们就传不了 `-o BatchMode=yes`（防止 ssh 卡在交互提示）与 `-o StrictHostKeyChecking=accept-new`（首次连接自动信任）。要保住这两项就得再生成一个 wrapper 脚本，多一个文件、多一层平台差异（Windows 的 `.cmd` 不能被直接 spawn）。除非将来要支持 `plink`/PuTTY 这类变体（那需要 `GIT_SSH_VARIANT` 与不同的参数语义），否则不换。

### 决策：脚本检查是独立三档，不再跟随「原生 git」档位

它原先是一个布尔值 `scanScripts`，而**判多宽**借用「原生 git」档位：`deny`/`ask` 下按宽匹配，`restrict` 下按命令位置判。结果是同一个设置在不同档位下含义不同，使用者无法直接表达"我要严格"或"我要不误伤"。

现在它就是三档（`scriptCheckPolicy`）：**严格**（提到即拒，等价于原来的宽匹配）、**限制**（只在命令位置判，基本不误伤）、**关闭**。命中一律**拒绝写入**，不再借用原生档位的 ask 语义 —— 档位表达的是"判多宽"，不是"要不要问"。

迁移：旧布尔仍被读取（`true` → 严格，`false` → 关闭）。**该键在 schema 上没有默认值**，原因见 PITFALLS 第 35 条：默认值会让迁移分支永远不可达。

### 决策：脚本内容在**写入时**判，而不是在 bash 调用时读文件

守卫跑在 dsh 进程里、对每一次工具调用同步执行，因此它内部任何同步文件系统访问都是给每次工具调用加阻塞风险（PITFALLS 29 记录的正是这条）。改为在 `write` / `edit` 时判内容：内容已经在参数里，零文件系统访问，命中时直接拒绝写入 —— 脚本根本不会落地。

- 换来的是：热路径没有 I/O；被拒的脚本不会以「先落地、执行时才拒」的形式留在磁盘上。
- 代价是：**以其他方式到来的脚本不被检查**（已存在的文件、别的命令生成的文件、嵌套脚本、here-document、运行时才确定的解释器、其它语言调用 git）。因此它是开关而不是承诺；判别宽度跟随「原生 git」档位。

### 决策：守卫自身 fail-open，并由单元测试承担可见性

一个抛出的守卫会掐断会话里的**每一次**工具调用，所以守卫内部任何错误都 `next()`（放行），并通过 `ctx.logger.warn` 与日志的 `guard.error` 报出来。代价是：**坏掉的守卫看起来和平庸的守卫一样**。因此测试直接驱动这个监听器，断言它确实在判定（拒绝、放行、`ask`、以及自身出错时不中断调用）。

### 定位：策略闸门，不是安全边界

守卫判定的是**参数文本与内容文本**：拦得住直接写法（`git status`、`cat ~/.git-credentials`、`read ~/.gitconfig`），拦不住运行时拼出来的路径、写进脚本再执行的命令、以及任何不经过工具的通道。真正的硬保证只有沙箱内遮蔽，而那是 dsh 的职责。README 与设置页都按这个强度描述，不假装更多。

---

## 五、日志与可观测性

### 决策：诊断日志默认开启

日志的价值在卡死**之后**：卡死前最后一行就是线索，而一个「需要先打开」的日志在最需要的时候不存在。代价是每次调用都多一次文件写入。

### 决策：同步追加写入

一行写完才返回，于是「缺了一行」只可能是真的没写，而不是烂在缓冲区里 —— 这个歧义正是排查期间造成误判的来源之一（PITFALLS 31）。它也是全插件**唯一**碰文件系统的地方，因此默认路径放在 harness 状态旁边，而不是 Windows 挂载盘上。

### 决策：调用日志与心跳是两个独立开关

两者回答不同问题：调用日志说「插件做了什么」（`guard.enter` / `guard.exit` / `tool.done`），心跳说「进程还活着吗、卡在哪」（每 5 秒一行，带半途调用的名字与时长）。可以只开心跳（存活探针）或只开调用日志（审计轨迹）。心跳默认关闭：它已经完成过一次定位使命，保留开关供调查时使用。

### 决策：始终记录 bash 命令的前 60 个字符

只写 `tool=bash` 看不出在跑什么，而「哪条命令引发卡死」是排查中唯一有用的线索。因此这一项不是开关，恒定记录，并做脱敏与长度限制。

### 原则：每个 `catch` 都要能回答「这里出错时谁会知道」

凡是「失败会改变某个判断语义」的位置，都不允许静默：审计无法运行必须与「没有发现危险键」区分开；日志读不到必须与「空日志」区分开；审批覆盖查询失败必须报出来；日志自身写入失败必须报给宿主终端（每条消息只报一次，避免坏日志把每次调用变成噪音）。

只有一处刻意保持安静，并在代码里写明理由：受保护路径的 `realpath` 失败 —— 那几乎总是「该文件不存在」，属正常情况，且词法比较已经完成、保护依然生效。全都报出来的话，真正的失败会被噪音淹掉。

### 决策：只记长度与判定，不记内容

日志不是转录。值一律截断到 200 字符，并对凭据 URL（`//user:pass@`）与令牌形状的字符串（GitHub token、`sk-…`、Authorization 头）额外脱敏 —— 转录不是唯一会泄漏令牌的地方，日志文件同样会。

### 决策：轮转按行数采样

超过 2 MB 轮转为 `.1`，但尺寸不是每行都 `stat`，而是每 200 行检查一次 —— 每次 append 都做一次系统调用，正是守卫被治好的那个毛病。

---

## 六、性能与缓存

### 决策：审计结果按「判定所依赖的全部输入」缓存

一次 `git_exec` 会起两个 git 进程（子命令 + 审计）。缓存的键覆盖运行目录、危险键策略、以及仓库配置的状态戳（`mtime + size`，含 `config.worktree`）；缓存里放的是**解析出的键名**，判定每次按当前子命令重新计算。

- 为什么不缓存结论：结论依赖子命令，而键名不依赖 —— 缓存材料比缓存结论安全得多。
- 为什么戳一变就失效：配置一改，状态戳就变，缓存自动失效，不需要手工失效逻辑。

### 边界是写明在代码、README 与测试里的

1. `[include]` 引入的文件：它们的路径不额外问 git 就看不到（而问 git 正是缓存要省掉的调用），所以对**被 include 文件**的修改由 **30 秒 TTL** 兜住，而不是立刻失效；
2. worktree（`.git` 是文件而非目录）：没有可监视的配置，于是**完全不缓存** —— 宁可每次都多跑一次，也不要一个无法验证的缓存。

**能写明的代价不是隐患，没写明的才是。**

### 决策：守卫热路径零系统调用

守在对每次工具调用同步执行的路径上，因此：受保护路径按配置缓存（配置变了才重算）；路径参数先做**纯字符串**比较；只有最后一段文件名与受保护文件名相同时才做一次 `realpath`（软链接唯一能藏身的情形）。依据是 drvfs 上的同步 `realpath` 会阻塞住整个 dsh 进程。

### 决策：所有扫描都有步数预算

同步循环转着的时候，事件循环上其它任何代码都不会运行，因此**定时器无法中断它**。唯一有效的是循环自己会去查的计数器：预算耗尽时 `spend()` 返回 false，扫描结束，守卫按**最严策略**拒绝并说明原因。预算设在远高于任何真实命令的量级，只有「这段代码没预料过的输入」才会触发。

「每一轮循环必须消费输入」也被写成结构性保证：修复 PITFALLS 31 的死循环时加了「若本次没有前进就强制前进一格」，这样以后任何分支忘记前进都不会再冻住宿主。

---

## 七、进程与恢复

### 决策：看门狗放在进程外

进程卡住时，进程内的一切都无能为力：设置页由那个进程提供（按钮点不动）、定时器不运行、JavaScript 写的信号处理器没有机会执行。所以恢复手段必须在**另一个进程**里：`tools/watchdog.sh` 用 HTTP 请求探测 dsh 自己的 web 服务（回答来自事件循环，超时即说明循环没在跑），检测到卡死后用 `kill -9` 重启（只有内核处理的信号能穿透）。它不依赖本插件，所以在「插件就是元凶」时照样有效。

### 决策：三层恢复，从「不改文件」到「卸载依赖」

1. `DSH_GIT_TOOL_DISABLED=1` —— 靠 patch 行里的 `disabled: !!js …`，启动即整行不加载，不改任何文件；
2. 删掉 `cordis.patch.yml` 里的那三行 —— `patchReload: live` 会在启动时重新读取，不需要卸载依赖；
3. `dsh plugin --profile <profile> remove git-for-dsh` —— 连同 `dsh.profile.bundles` 里的条目一起摘掉。

### 决策：运行时软开关与「完全不加载」并存

设置页的「启用本插件」是**运行时**开关：`git_exec` 拒绝调用、守卫停止拦截，但组件行仍在管线里。它的用途是做 A/B 对比（例如判断某次卡顿是否与本插件有关），因此必须免重启。而「插件导致 dsh 起不来」这类场景需要的是**完全不加载**，那只能由环境变量 + patch 行做到。两者都保留，因为回答的是不同问题。

### 决策：代理进程的所有权必须明确

只有**由本插件启动**的进程会注册在本插件 fiber 上、在退出时被收掉；端口上已经在监听的进程绝不触碰（那是用户的进程）。所有「这一次激活拥有的东西」（进程、句柄、缓存、订阅）都建在 `setup()` 里并由闭包传递，模块级只放只读常量 —— 跨激活共享可变的模块级状态会让集成测试与真实运行同时失准（PITFALLS 20）。

---

## 八、发行、构建与开发工具

### 决策：`lib/` 入库

pnpm 对依赖的构建脚本有 `allowBuilds` 门槛；把构建产物入库后，`dsh plugin add` 一条命令即可用，**安装路径上不存在构建脚本**，不需要放行，也不需要在本机装构建链。代价是 `src/` 与 `lib/` 两份内容必须保持同步：`npm test` 的 `pretest` 会先跑一次 build，改了 `src/` 而要它生效也必须 build。

### 决策：`src/` 就是运行时产物，不用打包器

Host 半是普通 ESM，Client 半是手写的 client module bundle（`window.__ModuleLoader__.load({ id, factory })`，`id` 为包名）。Profile 里不必装构建链，Client 产物可以直接阅读。代价：Client 半不能写 `import` / JSX，只能用 `require` 与 `React.createElement`，格式也不能猜（见 PITFALLS 14）。

### 决策：操作清单在构建期嵌进浏览器产物

`scripts/build.mjs` 把 `src/git-catalog.js` 的清单替换进 Client bundle（`__GIT_TOOL_CATALOG__`），于是勾选页与 Host 闸门读的是**同一份清单**，而浏览器不需要任何通往 Host 的运行时通道。代价：改完目录必须重新 `npm run build` 并强制刷新页面；因此产物里带**构建指纹**，设置页底部显示「页面版本」，与仓库里的 `lib/client.js` 一比即可判断浏览器是否在用旧 bundle（PITFALLS 3）。

### 决策：离线预览而不是靠断言检查排版

排版问题（挤在一起、两行并成一行）靠断言发现不了，得看。`scripts/render-preview.mjs` 用构建产物里的页面组件渲染真实 DOM、再套上 shell 的样式表，因此不需要登录、不需要浏览器会话。代价：它依赖构建产物，改完 `src/client.js` 要先 build 才有意义。

### 决策：验证脚本一律带控制组

凡是「某个危险行为没有发生」的结论，都必须先证明**加固之前它确实会发生** —— 否则「没看到标记」可能只是没武装（PITFALLS 10）。同理，闸门类测试必须断言**拒绝发生了**，而不只是断言命令跑通了（PITFALLS 7、21）。`verify-driver-hardening.mjs` 带两条显式控制组（未加固时两个标记都必须出现），`verify-config-audit.mjs` 带一条对照（不带 `--includes` 时同一个键必须不可见），两个脚本都调用**真实 git**，而不是只断言命令字符串。

---

## 九、明确不做的事

| 不做 | 原因 |
| --- | --- |
| 修改 dsh 源码、替部署方改沙箱策略 | 硬保证属于沙箱；插件只标注强度 |
| 管理、存储或注入凭据 | 插件的承诺限定在「自己不读取、不传递、不存储」 |
| 承诺「AI 读不到凭据」 | 同 uid 可读，且文件策略只限制写入 |
| 把工具守卫当作安全边界 | 它是参数文本匹配，拦不住运行时构造 |
| 把 `bash` 里的 git 调用改写/路由到 `git_exec` | 工具管线只能放行、拒绝、询问 |
| 缓存无法验证的东西 | worktree 里的审计结果直接不缓存 |
| 在守卫热路径做文件系统访问 | 同步 I/O 会把整个 dsh 进程一起拖住 |
| 在只读操作上追加写能力 | 写形式一律走写档审批或直接拒绝 |

## 十、改动前的检查清单

1. **改操作目录**：同步 `src/` 与形式规则、跑 `npm run build`、强刷页面；确认读写两档的默认值仍然保守。
2. **新增设置项**：先在命名空间里搜同名键（重名会静默覆盖，见 PITFALLS 28），再决定默认值，并同步 Host schema、Client 解码与文案。
3. **改闸门**：同时补一条「拒绝确实发生」的测试，以及一条真实 git 的验证脚本；不要只断言命令字符串。
4. **改断言**：先确认它断言的是正确行为 —— 断言也可能把 bug 焊死（PITFALLS 1）。
5. **改守卫**：不引入同步 I/O，不引入可能不前进的循环，并保证自身出错时仍然放行。
6. **改缓存**：确认缓存键覆盖判定所依赖的每一个输入；无法验证的输入就不要缓存，并把残余代价写进注释、README 与测试。
7. **改 Client 半**：确认 `exports.inject` 与实际读取的服务一致，且工厂体的 try/catch 与 error boundary 仍在。

### 决策：路径保护拆成三个互不相干的组

原先只有一份清单（`protectedPaths`）和一个档位（`pathGuardPolicy`），而它们同时承担两件毫不相关的事：

- **用户自己的清单** —— "这些地方 AI 不许碰"（某个文件夹、某个密钥仓库）。语义是"绝对"，不需要"询问"这种中间档；
- **内置的凭据与身份文件** —— 机器上已知的敏感文件，而**严重性并不相同**：凭据泄漏一次就是真泄漏，`~/.gitconfig` 里往往只是 name/email/proxy。

共用档位造成两个实际后果：想图方便放开 `~/.gitconfig` 时，**用户清单也跟着一起失效**；想临时关掉自己的清单，**只能把它删掉**。

现在三组各自带控制：

| 组 | 清单 | 控制 | 默认 |
| --- | --- | --- | --- |
| A 受保护路径 | 用户填写 | **开启／关闭** | **开启** |
| B 凭据 | 内置 | 禁止／询问／允许 | **禁止** |
| C 身份与配置 | 内置 | 禁止／询问／允许 | **询问** |

守卫对三组**独立判定**（命令文本、`file_path`、`path` 三种触发都按组走），并按组给出消息。某组设为"允许"会**落空并继续检查下一组**，因此放开 C 不会连带放开 A。

**迁移**：旧的 `pathGuardPolicy` 按偏严方向映射到 A —— `allow` → 关闭，`deny`/`ask` → 开启。

**三条操作纪律**（都是这次踩出来的）：

1. **废弃键必须保留 schema 声明**：校验会丢掉未声明的键，把 `pathGuardPolicy` 从 schema 里删掉会让迁移**永远读不到**它。保留声明，但不给它默认值；
2. **参与迁移的键不能有 schema 默认值** —— PITFALLS 35 的第三种形态；
3. **"键存在"不等于"用户选过"**：插件自身的 `base` 会铺开 `DEFAULT_CONFIG`，所以归一化只能对比**值是否等于默认**来判断，而不能看键是否存在。

### 决策：路径保护是一张黑名单表，每行三个复选框

原先的一份清单加一个档位同时承担两件不相关的事，于是想图方便放开 `~/.gitconfig` 时会连带放开用户自己的清单，想临时停用清单又只能把它删掉。现在每行独立：

| 勾选 | 行为 |
| --- | --- |
| 读 / 写 | 该操作静默放行 |
| 询问 | 任何访问先问，批准即放行该次 |
| 都不勾 | 静默禁止（不打扰） |

规则只有一处实现（`rowDecision`），内置行默认三项全不勾且不可删除，没有整表开关。

**两个真实缺口是这一轮才补上的**，都因为守卫的视野有边界：

1. **`workdir` 从未被检查** —— 守卫看的是工具参数，而 `workdir` 既不是 `file_path` 也不是 `path`，所以命令可以跑进黑名单目录里读个遍。现在目标目录也按行判定，且**黑名单优先于「目标范围」白名单**，消息点名该行；
2. **basename 子串匹配会误伤** —— `~/.config/git/config` 的文件名是 `config`，于是任何含 `config` 的命令（`git config …`、`cat ~/.gitconfig`）都命中那条全不勾的行而被拒。现在 basename 快捷匹配只对**点文件**生效，其余必须出现完整路径。取舍写明了：宁可漏掉一个罕见写法，也不要拒绝正常工作。

**迁移的行为变化**（README 也写了）：旧档位映射到复选框（禁止→全不勾、询问→只勾询问、允许→勾读写）；而旧的"关闭受保护路径"开关无法用勾选表达，因此原来被关闭的清单会以**全不勾（禁止）**出现 —— 方向更严，想放行请勾对应权限。

**bash 侧如实标注**：读/写只能从命令文本推断，所以它有一个档位（启发式／一律按写），并且文档与设置页都说明这是启发式。
