# 踩坑记录

这个文件收集本项目**实际踩过**的坑：每条都写清现象、根因与怎么避；有的条目附判据、谁守着或平台边界。

分工：`README.md` 描述**现在的设计与用法**，`DESIGN.md` 记录**设计决策与取舍**（为什么这样做、换来什么、代价是什么），这里记录**实际踩过的坑**：现象、根因、以及再动它时容易掉进哪里。

按危险程度排序：前面几条曾经让 dsh 起不来、让闸门空转、或让页面变成只读。

---

## 1. 客户端半的两份声明**不是二选一**

这一条前后错了两个方向，最终以真实事故收场，所以先讲结论：

> `package.json` 的 `dsh.client.inject` 装**包名**（模块图顺序）；
> 模块导出的 `inject` 装**服务名**（让激活等到服务就绪）。
> **两份都要写**，官方插件 `dsh-client-ui-settings-general` 就是两份都写。

### 第一错：读到未声明的服务 → 整个插件加载失败

**现象**：

```
Failed to load plugins
git-for-dsh
failed to apply loader entry 199c02e5 (git-for-dsh): cannot get property "slots" without inject
```

**根因**：模块导出的 `inject` 里**漏了代码实际读取的那个服务**（声明的是设置域服务，代码却读了 `ctx.slots`）。Cordis Guard 对**未声明的服务属性读取**直接抛错，而 `apply` 在页面启动流程里执行 → 整个插件加载失败。

### 第二错（更隐蔽）：把声明整个删掉 → 设置页永久只读

**现象**：插件加载正常、工具可用、日志干净，但设置页**所有控件禁用**。

**根因**：我据此推断"任何声明都会让包被永久 parked"，于是**删掉了整个 `exports.inject`**，全部改用 `ctx.get`。`ctx.get` 确实不会触发 Guard、也确实能优雅降级 —— 但它**同时丢掉了顺序保证**：

```text
apply 可能在客户端设置域注册 configForms 之前执行
  → ctx.get('configForms') 返回 undefined
  → 页面退化成 inert scope
  → 控件全部禁用（表现为"禁止用户修改配置"）
```

而那个"声明会 parked"的推断**从未被验证，且被官方插件直接否定**。

### 怎么避

- **服务名写进模块导出的 `inject`**（这是让激活等待服务的唯一机制）；
- **包名写在 `package.json` 的 `dsh.client.inject`**（模块图顺序）；
- `ctx.get` 只作为"这个环境可能没有它"的**额外**保险，不能替代声明。

### 谁守着（这次刻意写死了）

- `test/client.test.mjs` 断言 `exports.inject` **恰好等于** `['slots', 'configForms']`；
- `scripts/verify-served-bundle.mjs` 断言声明覆盖实际读取的服务。

**注意这两条断言原来写的是反的** —— 它们要求 `inject` **不存在**，等于把 bug 焊死。*断言也可能在表达错误的设计；修 bug 时要一并修断言。*

---

## 2. 服务必须声明：`ctx.get` 会**静默**返回 undefined

**现象**：插件看起来完全正常 —— 工具能用、页面能开、日志没有任何错误 —— 但**设置页的复选框全部禁用**（读起来就是"禁止用户修改配置"）。

**根因**：Host 半读全局配置用 `ctx.get('settings')`，但**没有在 `inject` 里声明 `settings`**。cordis 对未声明服务用 `ctx.get()` 读取时返回 `undefined`，**不报错**。于是注册设置命名空间那一段被整段跳过：命名空间不存在 → 客户端拿不到 → 只读。

**怎么判**：这一条最难在于"没有任何错误"。判据是问 Host 要事实：

```
registered      : true/false      ← 命名空间到底在不在
settings.writable: true
```

**怎么避**：**要用就直接声明**（`inject: [...]`）；`ctx.get()` 只作为"这个环境可能没有它"的可选回退。

**谁守着**：`test/host.test.mjs` 的 ctx 是代理，读取**未声明且实际存在**的服务会**抛错**。把 bug 重新引入 → 29 个断言失败。

---

## 3. 浏览器半可能仍在使用**旧 bundle**

**现象**：刚修完的 bug"又出现了"，怎么看都像没修。

**根因**：客户端 bundle 是页面加载时取回的模块。页面不重新加载，浏览器就一直用内存里的旧版本 —— 而症状与新修的 bug 一模一样。

**怎么判**：设置页底部有一行 **`页面版本 <构建指纹>`**（由 `scripts/build.mjs` 写入，是构建内容的 sha256 取前 12 位十六进制）。和仓库里 `lib/client.js` 里的指纹对比：**不一致就是页面旧了，强制刷新即可**。

**怎么避**：改完客户端先 `npm run build`，再强制刷新；报障时先要那一行版本号。

---

## 4. `host` 不在客户端 bundle 的作用域里

**现象**：插件**能加载**，但设置页显示"渲染失败：host is not defined"。

**根因**：`host`（以及 `styles`）是**动态 Client 求值器**的作用域参数 —— 那边是 `new Function('React','console','styles','host',…)`。composition 加载的 bundle 只从 `require` 拿到模块，**没有这些标识符**。

注意它**不是加载期错误而是渲染期错误**：`apply()` 里不碰它就不报，组件一渲染才炸。

**怎么避**：客户端半只引用 `React`（来自 `require`）和 `apply` 收到的参数。需要向 Host 要数据时，用构建期嵌入（本项目就是这么做的）或 `ctx.get` 到的服务，而不是 `host.call`。

**教训**：我当时用"把 `styles`/`host` 注入作用域"的 harness 验证，所以脚本里能跑、页面里炸。**harness 给的作用域必须与真实运行环境一致**。

---

## 5. `styles` 不是服务，样式表要自己插

**现象**：设置页**完全没有样式** —— 没有卡片、没有边框、勾选框和文字之间连间距都没有。

**根因**：我把样式表交给 `ctx.get('styles')` 去插。但 `styles` **不是 Cordis 服务**，而是一个调用方拥有的作用域内建值 → `ctx.get('styles')` 永远 `undefined` → 我自己的守卫"没有就跳过"把整段插入**静默跳过**了。

**怎么避**：bundle **自己**插 `<style>`（`document.head.appendChild`），`styles` 只在恰好存在时优先用。

**谁守着**：`test/client.test.mjs` 装一个假 `document`，断言 `apply` 后**恰好追加 1 个 style 标签**，且标签里含卡片规则与两行规则；渲染检查也会跑真实组件。

---

## 6. memory 持久化 ≠ 不可写

**现象**：复选框禁用，状态行说"本次会话内可改，但不会保存"。用户理解为"权限被禁"。

**根因**：两层错误叠加：

1. `scope.mutate()` **永远**走 `remote.settings.mutate`，**写入照样送到 Host**。`persistence: 'memory'` 只表示"客户端不把这个命名空间读回来当真相源"（非 loopback 页面会这样），**不代表不可写**。我把两者当成一回事，于是 `writable = status==='ready' && writable===true` 在 memory 模式下必然为假 → 全部控件禁用。**这是我造成的，不是环境限制。**
2. 即使能点，memory 模式快照里没有值，勾选后下一次渲染会**弹回未勾选**。

**怎么避**：可用性判据只排除"真的拿不到服务"的情况（`mode: 'inert'`）；页面用本地 `draft` 作为显示源，保证勾选立刻反映；写入失败要**显示出来**而不是静默回退。

**谁守着**：`test/client.test.mjs` 的三条断言：memory 模式不得禁用、只有 inert 才禁用、以及 `controlRows` 递归收集控件行。

---

## 7. 审计命令必须能被 git 接受，否则闸门**空转**

**现象**：配置审计全绿，但**什么都没做** —— 每次调用都放行。

**根因**：我让 git 跑 `config --local --includes --name-only -z`。真实 git 直接 **exit 129**（`error: no action specified` —— `--name-only` 必须与 `--list` 同用）。而"读不到配置"按设计**不算拒绝**（非仓库目录本来就无配置可读）。两个设计叠在一起 → 闸门永远读不到东西、永远放行。

**更糟的是测试**：单元测试只断言"命令字符串里含 `--includes`" —— 命令根本跑不起来，测试照样绿。

**怎么避**：

- 命令写成 `git config --local --includes --list --name-only -z`；
- 命令**只有一处定义**（`CONFIG_AUDIT_COMMAND`），闸门、单元测试、验证脚本共用，不再各写一份；
- 用**真实 git** 验证它能跑。

**谁守着**：`scripts/verify-config-audit.mjs` 第一条就是"命令 exit 0（一个被拒绝的命令会让闸门空转）"。

**通用教训**：单元测试**测不出**"命令本身无效"。凡是把字符串交给外部程序的闸门，都要有一个真实调用它的验证。

---

## 8. `--local --list` 看不到 `[include]` 进来的键

**现象**：仓库配置里一行 `[include] path = /任意绝对路径`，危险键**照样生效**，但审计看不到。

**根因**：`git config --local --list` **默认不展开 include**；`--includes` 才展开。（实测：被 include 的 `core.fsmonitor` 能执行，而 `--local --list` 对它显示 0 次。）

**怎么避**：审计必须带 `--includes`；本项目还把 `include.path` / `includeif.*.path` **本身**列为危险键 —— include 能把**机器上任意路径**的配置拉进来，且默认不可见。

**谁守着**：`verify-config-audit.mjs` 里有对照：**不带 `--includes` 时同一个键不可见**（这个对照项是必要的，否则"看得到"可能只是碰巧）。

---

## 9. 插件里的 `config` 闸门**不是**对抗 AI 的边界

**现象**：以为禁止了 `git config` 写入就堵住了配置注入。实际上仓库配置在**工作区内**，模型可以直接写那个文件。

**根因**：dsh 的文件策略限制的是**写入范围**，而仓库就在允许写入的工作区里。`git config` 只读化挡的是"**工具自己成为写入口**"，对"绕过工具直接改文件"无效。

**怎么避**：真正承重的是**命令级加固** —— 钉死 `core.fsmonitor`/`core.sshCommand`/`core.gitProxy` 等键 + diff 类强制 `--no-ext-diff --no-textconv`。它的性质是"**不管配置怎么来的都生效**"，因此直接写文件也绕不过。审计是补充（覆盖钉不死的通配键）。

**谁守着**：`scripts/verify-driver-hardening.mjs` 先**直接改 `.git/config`** 注入恶意键，再对比未加固/加固两种运行 —— 并带控制组。

---

## 10. 验证必须有控制组，否则"没看到标记"可能只是没武装

这一条踩了**三次**，每次都差点得出错误结论：

| 场景 | 假象 | 真相 |
| --- | --- | --- |
| 用双引号写 `core.fsmonitor` | "未加固也不执行 → 加固无效" | git 解析时吃掉引号，注入**根本没生效** |
| `execFileSync` 只在失败时返回 stderr | "程序没有执行" | 标记打在 stderr 上，成功时**看不到** |
| 首次 include 测试用相对路径 | "--list 也不显示 → 无盲区" | 路径没解析成功，include **压根没生效** |

**怎么避**：凡是"某个危险行为没有发生"的结论，都必须先证明**在加固前它确实会发生**。本项目三个验证脚本都带控制组。

---

## 11. 测试替身必须照实，否则缺陷会漏过去

夹具比真实实现宽松，就会把 bug 藏起来。已知的四处：

| 替身 | 真实的形状 | 宽松的后果 |
| --- | --- | --- |
| Host 侧设置作用面 | 能读、能观察、能写、能替换 | 写成 `subscribe`/`set`（那是浏览器侧 API）→ 假绿 |
| `ctx` 服务读取 | 读未声明服务**抛错** | 漏掉"声明缺失 → 命名空间没注册 → 只读页面" |
| `ctx.shell.resolve({})` | shell 自身的默认 workdir **不是**会话工作区；有会话的调用另由调用层把会话工作区戳进沙箱策略 | 在生产里跑到错的仓库 |
| `ctx.shell` 的 seam | 只有 `resolve(request)` 与 `execute(spec)`，handle 上是 `result()`/`kill()` | 写成 `run`/`start`：生产里**每一次 git 调用**都死，而夹具同款假件让整个测试套件全绿（第 36 条） |
| `ctx.approval` | 有 `overrideOf(session)`，插件靠它解释"策略 never" | 错误文案断言失效 |

另外：Host 测试要用 `applyUnguarded`（无兜底的入口），否则 `apply()` 的兜底会把"夹具坏了"变成"工具未注册"，把真实原因藏起来。

这道防线现在是**结构性**的：`test/host.test.mjs` 的假 shell 是一个只暴露 seam 方法的 Proxy，读到 `run`/`start` 这类方法直接抛错；适配器自身的行为由 `test/shell-driver.test.mjs` 钉住。

---

## 12. 审批策略和沙箱模式被预设**绑在一起**

**现象**：选了"完全权限"，结果审批策略变成 `never`，于是"写操作前询问我"不再弹提示、直接自动拒绝；用户以为是自己没设对，或者以为是插件在阻止。

**根因**：`dsh-permission-presets` 把两者绑成**同一条预设**：

| 预设 | 沙箱 | 审批 |
| --- | --- | --- |
| `workspace-write` | workspace-write | **ask** |
| `danger-full-access` | danger-full-access | **never** |

默认表里**没有**"完全权限 + 仍弹审批"的组合。

**怎么避**：知道这是**两条独立旋钮**被一条预设同时拨动。要测写档：把会话预设切到 `workspace-write`（= 受限沙箱 + ask）—— **这不影响插件的 git**，因为插件对自己的 git 调用显式指定了沙箱外执行；预设只影响 `bash`/`pwsh`/`read`/`write` 等工具。或者关掉插件里的"写操作前询问我"，靠允许清单 + 审计 + 参数闸门三层。

**注意**：审批策略按**会话**记录（写在会话日志里），新会话回到部署默认 `ask`。

---

## 13. 沙箱不管读取 —— 凭据不能靠插件保护

**现象**：以为"隐藏 `~/.gitconfig`、清空 `credential.helper`"就守住了凭据。

**根因**：dsh 的三种文件策略（`read-only`/`workspace-write`/`danger-full-access`）描述的都是**写入范围**。**读取不受这份策略约束**：实测在 `workspace-write` 下，工作区外的文件（含 `~/.git-credentials`）对模型仍然可读。

顺带两个实测结论：

- 强制注入的 `credential.helper=''` **确实**压住全局的 `store`（带控制组验证）—— 所以"允许读取用户级 git 配置"这个开关**换不来凭据能力**，只会让整份全局配置变得可读（用假 token 模拟验证过 token 会进入上下文）。
- `git config -f <任意文件>` 可以定向读仓库外的文件，因此被列为禁止形式。

**怎么避**：把"凭据不进 AI 上下文"当作**机器级/沙箱级**问题，而不是插件问题。本插件的承诺只有一条：**它自己不读取、不传递、不存储凭据，也不成为漏点**。至于密钥该由哪个 uid 持有、要不要放进模型看不到的命名空间，那是部署方的决定。

---

## 14. 客户端 bundle 是手写的，格式不能猜

**现象**：客户端半整个不生效，或加载报错。

**根因**：客户端半由 `dsh-client-modules` 作为**预构建产物**服务：`window.__ModuleLoader__.load({ id, factory })`，`id` 必须是**包名**，`factory` 接收 `require` 并返回 `module.exports`。写错 id、用 ESM `import`、用 JSX 都会失败。

**怎么避**：照 `src/client.js` 的骨架写（无打包器、可直接阅读）；`require` 只能请求包，跨插件协作走 Cordis 服务。

**谁守着**：`test/client.test.mjs` 用真实 `__ModuleLoader__` 执行源码，并断言"恰好注册一个模块且 id 等于包名"。

---

## 15. 插件为什么对 git 显式指定 `danger-full-access`

**现象**：会话沙箱内的远程操作失败 —— TLS 在 0.2 秒内报 `schannel: AcquireCredentialsHandle failed: SEC_E_NO_CREDENTIALS`；推送则卡在凭据助手上：`sh.exe: *** fatal error - couldn't create signal pipe, Win32 error 5`，随后 `fatal: could not read Username`。

**根因（实测）**：沙箱挡住的是 git 必须**捕获输出**的帮助进程 —— 凭据助手（经 `sh`）与 git 自带的 MSYS2 `ssh` 都要建 signal pipe，被沙箱拒绝；schannel 的凭证获取同样被挡。**本地操作在沙箱内本来就可用**：有会话的调用由调用层把会话工作区戳进沙箱策略，工作区内写入成功（**包括仓库 `.git`**），`init`/`add`/`commit` 实测全部可用，工作区外被拒；只有**无会话**的调用才回落到部署默认。

**怎么避**：插件对每次 git 调用显式传 `sandboxPolicy: { mode: 'danger-full-access', workspaceRoot }`，并由允许清单 + 参数闸门 + 配置审计 + 逐次审批替代沙箱作为约束；同时**自己**把 workdir 解析成会话工作区（不能依赖 shell 的默认值）。

**注意**：这一条只影响**插件自己的 git**。会话切到 `workspace-write` 预设不会妨碍它。

## 20. 跨激活的模块级可变状态

**现象**：代理功能的集成测试里，"端口一直起不来时必须拒绝"那条**没有拒绝** —— 因为上一条测试已经启动过代理，模块级标志还留着"已启动"，于是这次直接跳过探测与启动就放行了。

**根因**：把代理状态写成了**模块级**变量：

```js
const proxyState = { started: false, process: undefined }   // 错误：全局共享
```

它在**每次激活**之间共享。一个插件会被再次激活（重载、多会话），而"我启动过哪个进程"是**那一次激活**的事实；而且停止进程的 disposer 只属于一个 fiber。

**怎么避**：凡是"这一次激活拥有的东西"（进程、句柄、缓存、订阅），都建在 `setup()` 里，由闭包带给使用者，并由该 fiber 的 `ctx.effect` 负责回收。模块级只放**只读常量**。

**谁守着**：`test/host.test.mjs` 的 "host plugin: the operator proxy" 一组 —— 特别是最后两条：一条证明"起了代理并注入环境变量"，紧接一条证明"起不来时必须拒绝并杀掉进程"。**顺序本身就是检查**：如果状态跨激活泄漏，第二条会静默放行。

---

## 19. 只读档的三个写原语：分类不等于形式

**现象**（端到端实测）：`branch <名>`、`tag <名>`、`remote add` 都属于**只读档**，于是它们在**没有任何审批**的情况下创建了引用、写入了 `.git/config`。

**根因**：允许清单按**操作名**分类，而一个操作的只读性和它的**形式**有关 —— `branch` 列出分支是只读，`branch <名>` 是创建引用。目录里只记了前者。

**怎么避**：给这类操作补"形式规则"，并按性质分成两种处理：

| 变更形式 | 碰什么 | 处理 | 理由 |
| --- | --- | --- | --- |
| `branch <名>` / `tag <名>` / `remote prune` | 引用、网络 | **写档 + 审批** | 保留能力，与其它改状态的操作走同一道闸门 |
| `remote add/remove/rename/set-url` | `.git/config` | **直接拒绝** | 与 `config` 写入同类；`set-url` 静默改变后续 push 的去向，而那次 push 的审批提示里没有 URL |

**谁守着**：`test/git-catalog.test.mjs` 的 "a read-tier operation can have a mutating FORM"（断言只读形式 `mutating=false`、变更形式 `mutating=true`、配置类动词被拒），以及 `test/host.test.mjs` 里"变更形式触发审批、列出形式不触发"两条。

**教训**：**分类（tier）与形式（form）是两个维度**。只按操作名分档，就会给"只读操作"配一个能写状态的入口。

---

## 16. 短选项不能一律拒绝：`-c` / `-C` / `-u` 的含义取决于子命令

**现象**（端到端实测时一次性暴露三条）：

```
git switch -c newbranch   → 被拒（这里的 -c 是「创建」）
git commit -C HEAD        → 被拒（这里的 -C 是「复用提交信息」）
git add -u                → 被拒（这里的 -u 是「更新已跟踪文件」）
```

**根因**：把关卡写成"凡 token 等于 `-c`/`-C`/`-u` 一律拒绝"，用来挡全局的配置注入。但**全局选项只在子命令之前才被 git 采纳** —— 用真实 git 实测：

| 形式 | 结果 |
| --- | --- |
| `git -c core.pager=evil status` | 真正的注入形式（在子命令**之前**） |
| `git status -c core.pager=evil` | `error: unknown switch 'c'` —— git 自己就拒了 |
| `git status -C <另一个仓库>` | `error: unknown switch 'C'` —— **不会**重定向 |
| `git status --git-dir=<另一个>/.git` | `error: unknown option` —— **不会**重定向 |

也就是说：子命令**之后**的短选项只可能是该子命令自己的旗标（或 git 自己会拒的未知项）。原来那层拒绝**既无保护作用、又误伤日常操作**。而真正危险的位置（子命令之前）已经被"第一个参数必须是目录里的子命令"这条规则封住了。

**怎么避**：

- 短选项必须**带着子命令**判断：`-u` 只对 `fetch`/`pull`/`ls-remote` 才是 `--upload-pack`（指定程序），对 `add`/`commit`/`push` 是普通旗标；
- `-c` / `-C` / `--bare` 不再出现在"子命令之后"的拒绝表里（`switch -c`、`commit -C`、`init --bare` 都是合法用法）；
- 只在**全局位置**（第一个 token）拒绝这些名字。

**谁守着**：`test/git-catalog.test.mjs` 的 "a short flag means what the SUBCOMMAND says it means" 一组断言：既断言合法用法放行，也断言全局位置仍然拒绝。

**注**：这一条和客户端 `inject` 那条是同一个教训 —— **单元测试没有覆盖真实调用方式，是端到端测试先暴露的**。修好后要把旧断言一并改掉（它们原来断言的是错误规则）。

---

## 17. `pull` 分叉时 git 给的建议正是插件拒绝的那条路

**现象**：本地与远端历史分叉时 `git pull` 失败，git 的提示是：

```
hint:   git config pull.rebase false  # merge
```

而 `git config` 的写入形式**被插件拒绝**（见第 9 条）。照提示做会再撞一次墙。

**怎么避**：改用**同等效力的旗标**，它们都在允许清单里：

```sh
git pull --rebase origin main
git pull --no-rebase origin main
git pull --ff-only origin main
```

**这也说明插件的一个取舍**：把配置写入收成只读，代价是"git 让你改配置"这类提示不再可直接执行，必须换成命令行旗标。遇到这种情况，先看该操作有没有对应旗标，而不是去改配置。

---

## 18. 没有凭据时远程写操作失败，是**设计**而非 bug

**现象**：

```
fatal: could not read Username for 'https://github.com': terminal prompts disabled
```

**根因**：插件**默认**隐藏 `~/.gitconfig` 与系统配置，并清空 `credential.helper`（设置页的「允许 git 使用本机凭据」开启后，这两项都会恢复）。因此默认档下 git 拿不到 `store` 这类凭据助手，也无法交互式索要用户名。

**这是刻意的**：本插件的承诺是"自己不读取、不传递、不存储凭据"。需要认证能力必须显式开启「允许 git 使用本机凭据」（默认关闭）；不开启时认证只能发生在插件之外 —— 例如在 DSH 进程环境里设 `HTTPS_PROXY=…`（git 会继承，因为 `scrubbedParentEnv()` 保留代理变量）把出网与认证交给外部代理。

**判据**：只读的远程操作（`ls-remote`、`fetch`、`clone` 公开仓库）**正常**；写操作（`push`）失败在"读不到用户名"。这说明凭据通道被隔离生效了，而不是网络或插件坏了。

---

## 21. 盲替换改错调用点 → 闸门静默失效

**现象**：审计**不再运行**。测试报 `the audit must run`、以及四条"危险键必须拒绝"变成 `Missing expected rejection`。

**根因**：我给 git 子进程的环境加参数时，用同一段文本去做替换：

```js
env: buildEnv(),
```

这段文本在文件里有**两处** —— 一处是运行 git 的地方（该带设置），另一处是**审计**内部。而审计函数的参数 `policy` 是**危险键策略字符串**，不是设置对象，于是 `policy.current.useHostCredentials` 抛 `TypeError`，被审计自己的 `catch` 当成"这个目录没有配置可读"吞掉 → 返回 `null` → **一切放行**。

**两个教训**：

1. **替换必须有唯一锚点。** 用一行在文件里可能出现多次的短文本做定点修改，等于在赌；要带上足以唯一的上下文，改完立刻回读确认。
2. **`catch` 会把"我的编码错误"伪装成"正常情况"。** 审计的 fail-open 是对的（shell 不可用、非仓库目录），但它**分不清**"读不到配置"和"我自己抛错了"。现在参数类型在 `try` **之外**先校验，类型不对直接抛 —— 那是本文件的 bug，不该被吞。

**谁守着**：`test/host.test.mjs` 的 `host plugin: the repository-config audit` 六条 —— 它们**正是**这次报出问题的那组。这也说明"闸门类"测试必须断言**拒绝发生了**，而不只是断言命令跑通了。

---

## 22. `String.replace` 会解释替换文本里的 `$`

**现象**：改一个文件时，整个文件被拼接错乱 —— 插入点后面出现了文件**开头**的内容，语法直接报错。

**根因**：用字符串做替换时，替换文本里的 `$&`、`` $` ``、`$'`、`$1` 都是**特殊模式**（"匹配之前的全部内容"、"匹配之后的全部内容"、捕获组）。我的插入文本是正则的字符类，里面有 `$` 紧跟引号 —— 于是 `$'` 把**文件剩余部分**插了进来。

**怎么避**：替换文本里可能有 `$` 时，**用替换函数**：

```js
s.replace(anchor, () => inserted)   // 逐字插入，不做任何解释
```

**谁守着**：没有自动守卫 —— 这是纪律问题。改完**必须回读或 `node --check`**。

---

## 23. "末尾统一落盘"的脚本，中途失败会丢掉全部改动

**现象**：脚本报告前几步 `ok`，然后因某个锚点缺失而退出 —— 结果**前面那些"成功"的步骤一个都没生效**。我因此三次丢失改动（导入丢了、设置项丢了、函数丢了），并因为"报错说前几步 ok"而误判文件状态。

**根因**：脚本把 `let s` 在内存里依次替换，最后才 `writeFileSync`。中途 `process.exit(1)` → 内存里的改动全部丢弃。

**怎么避**：**每步立即落盘**（每步 `read → replace → write`），让失败点之前的改动保持有效；锚点缺失时明确报出是哪一步。

**代价与配套**：立即落盘意味着失败后文件是"部分应用"状态。所以配套要求是：**每步的锚点必须唯一**，失败后先 `git diff` 看清楚再继续（`git checkout --` 可回到干净基线）。

---

## 24. 同 uid 下：环境变量与 fd 可读，内存不可读

给"凭据只放在内存里"这种设计判死刑的一组实测（同一 uid，非父子进程）：

| 通道 | 可读？ |
| --- | --- |
| `/proc/<pid>/environ` | **可读** —— 所以"用环境变量把令牌递给 git"会泄漏 |
| `/proc/<pid>/fd/<n>`（别人打开的 fd） | **可读** —— 所以"用继承的管道/文件描述符递令牌"也会泄漏 |
| `/proc/<pid>/mem` | 不可读（Yama `ptrace_scope=1` 之类拦下了） |

**结论**：本机上，只要凭据要交给另一个进程，那个交接通道就是同 uid 可读的。**唯一**能给出硬保证的是"沙箱内根本看不到该文件"（挂载遮蔽），而那属于 dsh 沙箱的职责。

**为什么记下来**：这条把一大类看起来聪明的方案一次排除掉（内存保管、环境变量注入、fd 传递、socket 拉取），省得重新推演一遍。

---

## 25. `ctx.tools.guard` 动态插件够不到，`tools/pre-execute` 可以

**现象**：按 Inspect 目录写的 `ctx.tools.guard(fn)` 在动态插件里报 `is not a function`。

**根因**：**动态插件拿到的是受限服务表面**。实测 `Object.keys(ctx.tools)` 只有 `register, schemas, get`；包（`@deepseek-ai/dsh-tools`）公开的 `restrict` 与 `guard` 不在这份表面上。

**怎么避**：用 `tools/pre-execute` —— 它是**事件**（waterfall），`ctx.on` 就能监听，决定类型是：

```ts
{ kind: 'allow' } | { kind: 'deny'; reason: string } | { kind: 'ask'; reason?: string }
```

**两个必须记住的限制**：

1. **工具管道只能放行/拒绝/询问，不能改写参数** —— 所以"拦截 bash 里的 git 然后路由到插件"在工具层做不到。
2. **守卫的错误会被自己吞掉**：一个抛错的守卫如果继续抛，会掐断会话里的**每一次**工具调用，所以必须 `try/catch` 后 `next()`。代价是**坏掉的守卫看起来和平庸的守卫一样** —— 因此单元测试要**直接驱动这个监听器**（实测中就是它抓出了"函数没落盘、守卫抛 ReferenceError 却静默放行"）。

---

## 26. 代理：把「端口被占用」当成故障，恰好拒绝了唯一有用的配置

**现象**：首次远程操作看起来像「代理没起来」。按提示换了端口仍然不通，最后发现是**端口填错**：实际代理在 7897，设置里填的是 7890。

**根因**：最初的语义是「端口已被占用 → 提示换端口」，隐含假设是「端口被占 = 冲突」。但这台机器上代理**本来就在跑**，占用那个端口的正是要用的进程。于是唯一能直接工作的配置被当成错误拒绝了 —— 而现象长得像「插件不工作」。

**怎么判**：要问的不是「能不能在这个端口上起服务」，而是「这个端口上是不是已经有服务在监听」。端口测试由 **Host 侧**执行（不是浏览器去连），并且报告不只回答「通不通」，还会扫一遍常见代理端口、指出哪个在监听 —— 因为「端口填错」是这里最容易犯的错。

**怎么避**（现在的语义，四条）：

1. 端口已有服务在监听 → **直接使用它**，不启动、不停止（那是用户的进程）；
2. 端口空闲 + 有启动命令 → 执行启动命令，最多等 8 秒轮询端口，起来后注入代理变量；
3. 端口空闲 + 没有启动命令 → 拒绝，并指出该填哪一项；
4. 启动后 8 秒仍未监听 → 杀掉进程，并报出它的输出。

**教训**：「环境里已经有你需要的东西」不该写成错误分支。把正常的既有状态当成故障，代价是挡掉唯一可用的配置，而且故障现象指向完全错误的方向。

**谁守着**：`test/proxy.test.mjs`（端口探测、等待超时、代理环境变量注入）与 `test/host.test.mjs` 里的代理用例，特别是「起不来时必须拒绝并杀掉进程」那一条。

---

## 27. 日志只写 `tool=bash`：卡死无法定位，时间顺序还会被当成因果

**现象**：排查卡死时日志里只有一行 `tool=bash`，看不出当时在跑哪条命令；而且**闲置时开始**的卡死与**调用中开始**的卡死，在日志里长得一模一样 —— 「最后一行」很容易被误读成肇事者。

**根因**：两处可观测性缺口叠加。调用日志只记工具名，不记命令内容；并且没有任何东西在闲置时写日志，于是最后一行永远属于上一次调用。

**怎么避**：

- `guard.enter` 带上该次 bash 命令的**前 60 个字符**（脱敏后）—— 这是定位「哪条命令引发卡死」的唯一线索，因此**始终记录**；
- 增加**心跳行**（每 5 秒一行，报出当前卡在半途的调用及其已持续时长），把「卡在调用内部」与「闲置时卡死」分开。心跳默认**关闭**（它已经完成过一次使命：把一次挂起定位到某个调用内部），调查卡死时打开、平时关着；
- 两个开关**互相独立**：只开心跳 = 存活探针（日志里只有心跳行）；只开调用日志 = 审计轨迹。它们回答的是两个不同问题 —— 日志说「插件做了什么」，心跳说「进程还活着吗、卡在哪」。

**教训**：可观测性缺口本身就是一个坑：它会让人在错误的方向上耗掉大量时间（见第 31 条的排查过程）。把两个不同的问题合并到一个开关里，等于牺牲掉其中一个。

**谁守着**：`test/log.test.mjs`（两个开关互不影响、心跳行格式、脱敏与长度上限）与 `test/host.test.mjs` 的日志用例。

---

## 28. 设置命名空间里不能有两个同名键 —— 客户端会静默覆盖

**现象**：加"插件总开关"后，整个测试套件炸了：`$.enabled expected boolean but got …`，以及 `boolean true is not iterable`。

**根因**：我把开关命名为 `enabled`，而 `enabled` **已经存在**：它是允许清单那个**数组**。schema 里两个同名键互相打架；客户端的解码对象字面量里也是两个 `enabled`，**后者静默覆盖前者** —— 那会把整个允许清单变成一个布尔值。

**怎么避**：新设置项先搜一遍命名空间里有没有同名键。名字要**说清归属**：不是笼统的 `enabled`，而是 `pluginEnabled`（对照 `enabled` = 操作清单）。

**教训**：这次是**测试抓住的**，因为 schema 的类型校验在加载期就报错。如果两个键同类型，它就会静默错下去 —— 所以别指望测试兜住命名冲突，**先搜再命名**才是纪律。

---

## 29. 守卫在每次工具调用里做同步文件系统调用

**现象**：用户报告"AI 一调用工具，整个 WSL 就冻住"。关掉插件后不再出现。

**根因**：守卫跑在 **dsh 进程里（不在 shell 沙箱内）**，而它**每一次**工具调用都要：受保护路径 `realpathSync` ×2，再对**模型给的路径**（通常就在 `/mnt/d`）`realpathSync` 一次。`realpathSync` 落到 **drvfs（Windows 盘）** 上是同步调用 —— Windows 侧一忙就长时间阻塞，**卡住的是 dsh 进程本身**，表现即整个界面冻住。

**怎么避**：

- 受保护路径**按配置缓存**（配置变了才重算），而不是每次调用重算；
- 路径参数**先做纯字符串比较，零系统调用**；
- 只有**最后一段文件名**与受保护文件名相同时，才做一次 realpath（软链接唯一能藏身的情形）。

**教训**：`tools/pre-execute` 的守卫是**同步**的，且**在 dsh 进程里对每一次工具调用执行**。任何同步 I/O 都等于给每次工具调用加了阻塞风险 —— 在没有把握之前，宁可退化成纯内存比较。

**谁守着**：`test/host.test.mjs` 的守卫用例仍然驱动真实监听器（拒绝/放行/ask/allow 与"自身出错不得中断调用"）。

---

## 30. 页面解码表与 Host 档位表漂移 → 选项"看起来无效"

**现象**：用户在设置页把原生 git 改成「限制」，保存后**又变回「禁止」**，看起来改不动。

**根因**：原生 git 的档位从 3 个（deny/ask/allow）扩到 4 个（多了 restrict），而客户端**解码**那一步还在用旧的 3 档表校验。

存进文档的值是对的，**只是显示回落到默认** —— 所以现象像"设置无效"，实际是"读数错了"。

**怎么避**：**同一份选项集不要维护两份**。测试上更要紧的是判据：

```js
// 从 Host 的 catalog 读出它接受的全部 id，逐个喂给客户端解码器，断言原样通过
for (const id of NATIVE_GIT_POLICIES) {
  assert.equal(decode({ nativeGitPolicy: id }).nativeGitPolicy, id)
}
```

**教训**：两张必须一致的表，就该有一条**往返测试**；照抄 id 列表的测试抓不住漂移，因为它和实现一起漂。

---

## 31. 一个死循环 —— 而且它只和 bash 有关（真凶）

**现象**：装上插件后，AI 每次走 bash 就可能整台 WSL 冻住几十秒；插件一卸载就完全不出现。用户反复强调"这是插件的 bug""这个 bug 在日志出现之前就有"——两次都对。

**根因**：`invokesGit`（`restrict` 档的判别器，只在 bash 分支调用）里，`<` 和 `>` 属于 `WORD_BREAK`（结束一个词）**却不在 `COMMAND_START`**，而且没有任何分支处理它们：

```js
const start = index
while (index < command.length && !WORD_BREAK.has(command[index])) index += 1   // < > 立刻命中
const word = command.slice(start, index)   // → word 为空，index 原地不动
```

于是外层 `while` **永不前进**。守卫跑在 dsh 进程里、对每次 bash 调用都执行，所以**任何带重定向的命令**（`2>&1`、`2>/dev/null`、`> file`）都会把整个进程绞死 —— 而这类重定向几乎每条命令都有。

**它解释了全部观测**：只有 bash 会卡（判别器只在 bash 跑）；写入从不卡；卸载插件就不卡；卡死都在"守卫窗口内"（enter 有、exit 无）；**心跳同时消失**（事件循环被占死）；磁盘与内存空闲而 CPU 恰好是**一个核**的量（16 核机器上显示 6.5%）；只有部分调用卡（取决于命令里有没有重定向）；需要 `restrict` 档（`deny`/`ask` 用 `containsNativeGit`，那里没有这个环）。

**怎么避**：

- **每一轮循环必须保证消费输入**：修复里加了结构性的 `if (index === start) index += 1`，这样以后任何分支忘记前进也不会再冻住宿主；
- 当循环遍历的是**用户输入**时，先把畸形输入列进测试（`>>>`、`<<<`、`a<>b`、`""`…）；
- **给测试加超时**（`--test-timeout`）：这一族 bug 表现为"挂住"而不是"失败"，没有超时它就一直是隐形的。

**教训（比 bug 本身更值钱）**：我连续给出了三个错误结论 —— "环境问题""同步日志写冻住事件循环""需要更多采样" —— 而真因是一段**纯逻辑死循环**。我之所以没找到，是因为一直困在**性能/资源**的框架里（同步 I/O、drvfs、内存、杀毒），而没有问最朴素的问题：**这段代码在任何输入下都会返回吗？** 用户的判断（"就是插件""和 bash 有关""日志之前就有"）三次都比我的分析准。

---

## 32. 同步循环只有计数器能救，进程卡死只有别的进程能救

**背景**：第 31 条那个死循环让整个 harness 冻住。事后要问的不是"怎么写出没有死循环的代码"（那做不到 ✗），而是"**怎么让这类错误不再冻结整个系统**"。

**两条结论，都反直觉**：

**① 定时器救不了同步循环。** 我最初想的是"加个看门狗，超时就中断" ✗ —— 但**同步循环转着的时候，事件循环上其他任何代码都不会运行**，定时器回调根本没有机会被调用。这一点在日志里有直接证据：卡死时**心跳戛然而止**，因为写心跳的回调就是被饿死的那个 ✗。
⇒ 唯一有效的是**循环自己会去查的计数器**：`createBudget()` 让 `spend()` 在预算耗尽时返回 false，把它放进循环条件里，扫描就自己结束了 ✓，守卫随后按**最严策略**拒绝并说明原因（宁可拒一条超长命令，也不能冻住宿主）。预算设在远高于真实命令的量级，只有"代码没预料过的输入"才触发 ✓。

**② 进程卡死时，进程内的一切都无能为力。** 我一开始想做"设置页上的重启按钮" ✗ —— **设置页正是由那个被卡住的进程提供的**：它点不动、接口不回、定时器不响 ✗✗。
⇒ 只有**另一个进程**能动手。仓库里因此带了 `tools/watchdog.sh`：它**探测 dsh 自己的 web 服务**（回答来自事件循环 → 超时就说明循环没在跑 ✓），检测到卡死后用 **`kill -9`** 重启 —— 事件循环卡死时 JavaScript 写的信号处理器没有机会运行，**只有内核处理的信号能穿透** ✓。它**不依赖本插件**，所以在"插件就是元凶"时照样管用 ✓。

**附带的一般原则**：

- 任何跑在宿主进程里、又是**同步**执行的代码，都要按"**它可能冻结宿主**"来设计，而不是按"它应该很快"；
- 系统要留一条**不必加载出问题组件就能启动**的逃生口（这里是 `DSH_GIT_TOOL_DISABLED=1`，以及设置页里那个"软开关"的区别）；
- 顺手修掉的是真优化：`isCommandStringQuote(command.slice(0, index))` 每个引号都复制一遍前缀，是 O(n²) ✗ —— 改成向前短扫描后，2 万引号的命令从灾难级降到 **2ms** ✓。

---

## 33. 失败必须有人知道 —— 静默失败清单

**做法**：把插件里每一处 catch 都过了一遍，问同一个问题：**这里出错时，谁会知道？** 找出四处真的会"悄悄过去"的地方：

| 位置 | 原来 | 现在 |
| --- | --- | --- |
| 日志自己的写入 | 吞掉 —— 日志坏了看起来像"今天很安静" | 通过回调报给宿主终端（**每条消息只报一次**，免得坏日志把每次调用变成噪音） |
| **仓库配置审计** | 直接返回 null —— "审计没跑成"与"没发现危险键"**完全同形**（这正是当初"审计静默失效"那个 bug 的残余） | 明确报出"本次未做审计 + 原因"；关键键仍由强制配置钉死，所以调用继续 |
| 日志读取路由 | 读失败被吞成空字符串 —— **读不了与空日志长得一模一样** | 只有文件不存在保持安静，其它错误报到页面上 |
| 审批覆盖查询 | 失败等同于"用户没有覆盖" | 报出来 |

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

**教训**：空的 catch 是最容易写出"安全网失效数周无人知"的语法结构。每次写它都该问：**这个失败会不会改变某个判断的语义？** 会（审计→没发现、读取→空、覆盖→没覆盖），就必须报；不会（可选路径不存在），才可以安静 —— **且要在代码里写清为什么**。

---

## 34. 缓存一个安全判定：键必须覆盖每个输入，代价必须写明

**背景**：`git_exec` 每次调用要起两个 git 进程（子命令 + 配置审计）。审计结果显然可以缓存 —— 但**"显然"通常就是安全网变弱的地方** ✗。

**怎么做得安全**：

- **缓存原始材料，不缓存结论**：缓存里放的是"解析出的配置键名"，判定每次按当前子命令**重新计算** ✓ —— 键名不含子命令，所以子命令不该进缓存键；而结论依赖子命令，所以它根本不该被缓存；
- **键覆盖每个输入**：目录 + 策略 + 仓库配置的**状态戳**（mtime+size，含 `config.worktree`）✓；配置一改戳就变，缓存自失效 ✓；
- **无法验证就不缓存**：worktree 里 `.git` 是文件、没有可监视的配置 → 直接不缓存 ✓（宁可每次多跑一次，也不要一个无法验证的缓存）；
- **把残余代价写出来**：`[include]` 引入的文件路径，不额外问 git 就看不到（而那正是要省掉的调用）→ 对**被 include 文件**的修改由 **30 秒 TTL** 兜住，不是立刻 ✗。这一点写进了代码注释、README 和测试 ✓。**能写明的代价就不是隐患，没写明的才是。**

**顺带一条关于守卫的实践**：这次我用 heredoc 把脚本内联在 bash 命令里，结果**被自己的守卫拒了** —— 正文里含有看起来像 git 调用的片段，命中了 `restrict` 判别器 ✓。一直以来的做法（用 write 工具写脚本、bash 里只留一条短命令）反而不会被误伤 ✓。**这是"内容级检查"的必然代价**：它会把"写代码"误判成"跑命令" ✗ —— 拒一条命令可以接受，漏掉一次真调用不行 ✓。

---


## 35. Windows 的空设备 `NUL`：git 认它，Node 不认 —— 而且 Node 会把它写成真文件

**背景**：`buildEnv()` 用**平台空设备**隐藏用户级配置、并让仓库钩子不被运行（Windows 上是 `NUL`，其余是 `/dev/null`）。Windows 侧做真机验证时，验证脚本要在工作目录里检查这个设备。

**根因**：`NUL` 是**设备名**，不是文件路径 —— 它由 Win32 在**打开时**解释。git for Windows 走 MSYS2 打开它，行为与 `/dev/null` 完全一致（真机 22 项断言全绿，其中两组是对照实验：未钉住时钩子真的把提交拦下、用户级配置里的键真的读得到；钉住后钩子**从未**运行、那个键读不到）。但 **Node 的 `fs` 不解释设备名**：`existsSync('NUL')` 为 `false`，`statSync`／`readFileSync` 报 ENOENT；更糟的是 `fs.writeFileSync('NUL', …)` —— 它会经 `\\?\` 长路径在**当前目录创建一个真实的文件 `NUL`**。

**代价**：那个真文件**删不掉**（资源管理器、PowerShell `Remove-Item`、libuv `unlink` 都失败），只能用 `fs.unlinkSync('\\\\?\\' + 绝对路径)`；而它留在工作树里会让 `git add -A` 直接失败（`fatal: unable to stat 'NUL': No such file or directory`，exit 128）—— "清理自己留下的痕迹"于是变成一次解谜。

**怎么避**：**不要用 Node 的 fs 去校验或写入空设备**。要验证就验证**谁在解释它**：给 git 一组对照实验（钩子是否真的没跑、用户级配置是否真的读不到），而不是检查"环境变量里写了什么"。清理时优先用 `\\?\` 前缀的绝对路径，或干脆避免对设备名做文件操作。

**一般化**：**跨平台的值要按"谁解释它"来验证** —— 同一个字符串交给 shell、git、Node 还是内核，解释规则各不相同，"我这边看着对"证明不了任何事。

**谁守着**：`test/git-catalog.test.mjs` 断言 `NULL_DEVICE` 按平台取值、且强制配置表与它一致；Windows 真机侧本次另用了一份 `verify-nul-device.mjs` 做行为对照。

---

## 36. 假 shell 实现了不存在的 API —— 工具全死、测试全绿

**现象**：模型每次调用 `git_exec` 都拿到同一句话，工具等于不存在（子命令、路径、审批设置都无关）：

```
git status could not start: ctx.shell.run is not a function
```

**根因**：harness 的 shell 服务（`ShellExecutor`）只声明两个方法 —— `resolve(request)` 与 `execute(spec) -> ShellExecution`，进程句柄上是 `result()` 与 `kill()`。插件却按 `run(spec)` / `start(spec)` 调用，这两个方法**从来不存在**。三处调用点全中：git 调用、仓库配置审计、代理启动。

而 `test/host.test.mjs` 的假 shell 实现的**正是那套不存在的 API**，于是插件与夹具互相印证，整个测试套件全绿 —— 缺陷只能由真实运行暴露。两个连带后果：

- **仓库配置审计一直是静默失效的**：审计自己的 shell 调用同样抛错，被 fail-open 的 `catch` 当成"这个目录没有配置可读"吞掉（第 33 条列出的那处），所以那套危险键闸门在生产里从未真正跑过；
- **代理启动还有第二个坏点**：它传 `timeoutMs: 0`，而真实 `resolve` 只接受正的有限值（0 不是"关掉超时"的公开哨兵）。长驻进程要去掉期限，用的是 `onExpiry: 'none'`。

**怎么修**：把 seam 收进一个模块（`src/shell-driver.js`），插件只依赖它的两种形态 —— `runOnce(request)`（一次性命令：resolve → execute → await `result()`）与 `startBackground(request)`（长驻进程：保留句柄、不 await result、强制 `onExpiry: 'none'`）。调用点不再各自猜方法名，而 seam 的名字只有一处维护。

**怎么避**：

- **假件必须照真实契约写**（第 11 条），并且要**结构性**地保证：宿主测试里的假 shell 是只暴露 seam 方法的 Proxy，读到 `run`/`start` 这类方法直接抛错；
- 契约另有一条测试（`test/shell-driver.test.mjs`）：seam 缺失时给出的错误必须**指名缺哪个方法**并说明这是插件/harness 不匹配，而不是 `is not a function`；
- 收到 `is not a function` 这类框架层报错时，先怀疑自己调的 API 不存在，再怀疑参数；
- `timeoutMs: 0` 这类"我以为是关掉超时"的哨兵，要对着实现的校验写，而不是对着自己的意图写 —— 假件的 `resolve` 也照真实实现校验，这条才在测试里挡得住。

**谁守着**：`test/host.test.mjs`（Proxy 假件，外加 "pins the fixture to the seam the driver declares"）与 `test/shell-driver.test.mjs`（适配器本身）。

## 37. 守卫只认工具名 `bash` —— 在 Windows 上是空覆盖

**现象**：插件在 Windows 上「看起来」有原生 git 拦截，实际 `pwsh` 里一句 `git --version` 照常执行。

**根因**：命令文本那一层的判定写在 `execution.name === 'bash'` 分支里，而 Windows 的 shell 工具叫 `pwsh`。设置页的「原生 git」「路径判定」照旧显示、读数照旧生效，只有行为是空的 —— 同一条判定在 Linux/macOS 上有效，换到 Windows 就等于没有。

**怎么判**：插件启用状态下直接跑一条原生 git（`pwsh` 里 `git --version`）。它成功执行，就说明拦截没生效。

**怎么避**：判定按**工具名集合**（`bash` 与 `pwsh`）而不是单个名字；拒绝消息也带上调用方实际用的工具名，否则报错会把排查引向另一个 shell。

**谁守着**：`test/host.test.mjs` 的 "judges the same command text when the shell tool is pwsh" —— 同一段 `git status` 换成 `pwsh` 必须同样被拒，而 `Get-ChildItem` 必须放行。

## 38. 渲染原始 section，内置行整表消失

**现象**：设置页「路径权限」只剩输入框，五行内置行看不见了 —— 但拦截仍然生效。

**根因**：Host 的 schema 给 `pathRules` 的默认是 `[]`（"用户还没加"），而内置行只存在于**客户端解码器**里。组件渲染的是 `snapshot.value`（原始 section）而不是 `decodeSection(...)` 的结果，兜底那一支因此永远不执行；只测解码函数的用例看不到这一点。

**怎么判**：打开「设置 → Git 工具 → 策略与代理 → 路径权限」。空表就是内置行没渲染。

**怎么避**：渲染取解码后的值（默认值集中在一处），不要直接取原始 section。

**谁守着**：`test/client.test.mjs` 的 "renders the built-in protection rows when the stored section carries none" —— 它断言渲染出的行里包含内置路径，而不是只断言解码结果。

## 39. 前缀比较写死了 `/` —— Windows 上「点名受保护文件的目录」不设防

**现象**：把受保护文件的**父目录**当路径参数传进来（`grep` 的 `path`、`read` 的目录参数）没有被拒。点名文件本身会被拒，所以闸门看起来是好的。

**根因**：`reachesProtectedPath` 的祖先分支用 `protectedPath.startsWith(candidate + '/')` 判断，而前缀里的分隔符是**硬编码的 `/`**；守卫先用 `path.resolve` 解析参数，Windows 上两侧都是反斜杠，`startsWith` 永不成立。同文件的 `isPathInside` 本来就按 `[\\/]` 分段、Windows 忽略大小写 —— 两条判断对同一对路径给出相反答案，缺陷就在这个不一致里。

**怎么判**：拿一个真实存在的受保护文件（例如 `~/.gitconfig`），先点名文件本身（必须被拒），再把它的父目录作为 `grep` 的 `path` 传进去。父目录也必须是 `deny`；在 Windows 上它会是 `delegated`。

**怎么避**：容器判断一律用共享的 `isPathInside(protectedPath, candidate)`，任何一个分支都不要自己拼分隔符。

**谁守着**：`test/host.test.mjs` 的 "refuses a protected file, and a directory that contains it" —— 它断言的正是这一条，因此在修好之前它是红的。

## 40. `--out` 用 `new URL()` 解析参数 —— Windows 上绝对路径必坏

**现象**：`node scripts/build.mjs --out D:\some\dir` 抛 `ERR_INVALID_URL_SCHEME`；改用 `file:` URL 传就正常 —— 绕过它等于把这个缺陷藏起来。

**根因**：解析参数用的是 `new URL(arg, file://<cwd>/)`。`D:\some\dir` 在 URL 语法里被当成 scheme 为 `d:` 的 URL，`fileURLToPath` 随即抛错。

**怎么判**：在 Windows 上用普通路径跑一次 `--out`。

**怎么避**：先判断参数是不是 `file:` URL，不是就按普通路径 `resolve()`；验证脚本按**普通路径**传参，这样这条路径始终被覆盖。

**谁守着**：`scripts/verify-artifacts.mjs`（按普通路径调 `--out`）与 `test/artifacts.test.mjs`。

## 41. 拿 `JSON.stringify` 的结果做子串匹配 —— Windows 上永远匹配不到

**现象**：断言「解析后的目录到达了 shell」在 Windows 上恒为假，而同样的断言在 Linux/macOS 上是对的；路径本身完全正确。

**根因**：断言写的是 `JSON.stringify(recorded.runs).includes(real)`，而 `JSON.stringify` 会把路径里的反斜杠转义成 `\\`，于是原始路径字符串永远不会出现在那串 JSON 文本里。

**怎么判**：断言在 Linux 绿、Windows 红，且涉及的字符串里有 `\\`。

**怎么避**：要匹配序列化结果，就把**查找串也序列化**（`JSON.stringify(path).slice(1, -1)`），或者直接在解析后的结构上比较字段，别拿原始字符串去搜 JSON 文本。

**谁守着**：`test/host.test.mjs` 的 "hands the shell the directory the rules judged" —— 它在 Windows 上真跑（junction）时会走这条判断。

## 42. 受限进程里 `realpathSync` 打不开重解析点 —— 这不是 Node 或 Windows 的行为

**现象**：在沙箱里建好的 junction，`realpathSync` 不解析它（返回链接自身），于是「链接与目标必须给出同一个答案」这条断言在沙箱下无法成立、只能 skip；一旦放开文件策略，同一份代码立刻解析正确。

**根因**：`realpathSync` 走的是打开句柄再取最终路径，而受限令牌下的子进程打不开重解析点句柄。**这是进程权限的差别，不是平台能力**：同一个 junction 在非受限进程里解析得好好的。

**怎么判**：用同一个脚本分别在受限与非受限下跑一次 `realpathSync(link) === realpathSync(target)`；受限下为假、非受限下为真，就说明是权限而不是缺陷。注意 Windows 上**文件**符号链接还需要开发者模式或特权（`EPERM`），那一条与沙箱无关。

**怎么避**：需要重解析点的测试要么用 junction（目录可用、无需特权），要么按能力探测后**带原因 skip**；别改成硬链接顶替——硬链接的 `realpath` 是它自己，断言会失去意义。

**谁守着**：`test/host.test.mjs` 的 `canCreateResolvableLink` 与两条链接用例的 skip 理由。

## 43. 只在完全权限下验收 —— 受限会话里 `npm test` 的默认按文件隔离会 EPERM

**现象**：`npm test` 在受限会话里 7 个测试文件**全部** red，每个只报一句 `test at <file>:1:1 — Error: spawn EPERM`；同一条命令在 `danger-full-access` 下却全绿。

**根因**：node 测试运行器默认**按文件隔离**，每个文件起一个**带管道 stdio** 的子进程，而受限沙箱不允许管道（与条目 42 同一家族的限制）。完全权限下这层限制不存在，于是「我刚跑过，是绿的」是个假象 —— 验收环境的权限比使用环境宽，等于没验收。

**怎么判**：失败形态是「每个测试文件各一条 `spawn EPERM`，且一条断言都没跑到」（`tests` 数等于文件数、`pass 0`）。在命令后加 `--test-isolation=none` 立刻全绿，就是它。

**怎么避**：`npm test` 固定带 `--test-isolation=none`（本套件内部不需要文件级隔离）；**在受限会话里验收**，别只在不受限时跑。

**谁守着**：`package.json` 的 `test` 脚本本身。




