危险命令与凭据路径编辑,在下发之前就被物理拒绝。
allow-version 确认它真的在工作
1. **已装载**:设置页出现「danger-guard 危险拦截」这一行,且 `dsh --profile web --dump-config` 出现本包的层。
- `--dump-config` 是唯一被接受的"进了层栈"信号,而它**不是只读命令**——它会重写该 profile 的根装配文件。所以判读要分别捕获 stdout 与 stderr;未匹配的 patch 目标只出现在 stderr,只看退出码什么都证明不了。
- 层行可能是 `# == <层>`,也可能是 `# == <层>, patched by <路径>`;判据必须容得下后者,否则它会对着真实存在的层报"没装"。
2. **有拦截**:在一个会拦它的预设下(仅可查看、工作区内修改与完全权限都拦;自动审查档放行),让 agent 跑 `git commit --no-verify`。这次调用本身必须失败并给出 `Error: <理由>` 与一条出路。若"行为毫无变化",读法是**闸门没装**,不是规则太松。
3. **设置**:打开卡片改一个值,`保存` 由 disabled 转 enabled,写入回 200。非本机 loopback 页面上控件禁用并显示只读状态。
4. **读数**:打开卡片(以及手动「刷新」)会走一次 `dangerGuardStats` 命名空间的 Remote 调用。按需拉取、不订阅——判定统计只在 agent 干活时变,而跨宿主重连缓存一份旧值正是这套设计要避开的失效。
##
它不提供什么
找之前先知道这几件事:
- **它不注册任何工具。** 没有 `guard_status` 这类模型侧工具——整个面就是 `ctx.tools.guard` 谓词加两条预检瀑布。
- **它没有自己的 HTTP 端点。** 它只注册一个 Remote 命名空间 `dangerGuardStats`,用途只有一个:经宿主 `/api` 网关给设置卡的读数块供数(无参、JSON 结果的方法)。除此之外没有别的路由,没有可从外部调用的 `/api/dangerGuard…` 面;本包内也没有 CSRF 与信任闸门——那整层归宿主网关。
- **它不加 CLI 子命令。** `dsh danger-guard …` 不存在。
- **它不落盘、不联网**(唯一的一次只读子进程见「数据与隐私」)。
- 它**可选**消费的东西:`lessonLoop` 总线(report/pass)、`sessionProjections`(工具台账)、`fs`(预检取证)、`workingDirectory`(当前目录基准)。这几样都是可选子注入——没有它们的 profile 照样拦,只是走回退路径。
##
在 dsh 里启用
- 组合包形态:`package.json` 的 `dsh.bundle.patch` 指向 `cordis.patch.yml`,由 `dsh plugin add/remove` 维护装配层。
- 补丁是**两行(bulkhead / 隔舱)**:`- id: danger-guard`(拦截半,**没有 `Config`**,name 指向包本体)与 `- id: danger-guard-settings`(设置半,严格 schema,name 指向 `./settings` 子路径)。
- 卡片只由第一行带出:宿主只对**裸包名**条目扫 `dsh.client`,所以设置行写成子路径是有意的,不是漏改。
- 卡片只在 web 平台装载,位置是设置页插件列表里的「danger-guard 危险拦截」。
- 部署默认值写在**设置那一行**的 `config:` 上;优先级是 设置卡运行时值 > 行 config > 内置底座(`lib/settings-schema.ts` 的 `BUILTIN_BASE`)。
- 行里的显式 `undefined` 不覆盖底座;非法数值(如 `maxDenies: 0`)回落内置默认——`0` 等于把事实门整道关掉。
- 写在拦截那一行的默认值是无效的:那个条目根本不收 `config` 参数。
- 装载失败的方向是保守的。严格 schema 只挂设置行,所以一行被改坏的用户配置只让**那个条目**失败;拦截条目回落内置底座并告警一次。`apply` 只在设置面本身缺件时抛错。缺 `tools` 服务不抛错(注册走可选链),但 `tools` 在 `inject` 里,于是 cordis 根本不激活该条目,启动诊断会点名它——"闸门没装,且宿主说了"。
##
规则拦什么
| 族 | 拦 | 放行与出路 |
|---|---|---|
| git 钩子绕过 | `commit` / `push` / `merge` / `cherry-pick` / `rebase` / `am` 上的 `--no-verify` 及其无歧义缩写;`commit` / `am` 的短形式 `-n`;`git -c core.hooksPath=` 与 `git config core.hooksPath
设置项
命名空间 `danger-guard-settings` 即设置条目 id;默认值是 `BUILTIN_BASE`。
| 字段 | 类型 | 默认值 | 作用 |
| --- | --- | --- | --- |
| `enabled` | boolean | `true` | 总开关;`false` 时三类判定全部静默,连教训上报也不发 |
| `factGateEnabled` | boolean | `true` | 只关首次编辑事实门;危险命令与密钥面照拦 |
| `preflightEnabled` | boolean | `true` | 编辑前预检。关掉只是退回"拒绝后由模型自己补证",判据一条不减 |
| `judgeStats` | boolean | `true` | 采样判定耗时与输入规模:本地滚动摘要,加上装了 lesson-loop 时每次判定一条 `judge-cost` pass(只带面名/毫秒/字符数)。卡片上的统计读数不受它管,它也不管拒绝上报里那几格 `judgeMs` |
| `maxDenies` | natural,1–5 | `2` | 同一目标连续"未收敛"拒绝的次数上限,超过即配额放行并告警 |
| `strictMode` | boolean | `false` | `true` 时忽略写面分档,每个代码文件首次编辑都走全探查 |
| `smallEditChars` | natural,≥1 | `200` | 写面 ≤ 此值且不含签名关键字 → 判小改,只要求 read |
| `bigEditChars` | natural,≥2 | `2000` | 写面 > 此值 → 判大段重写,升最高档全探查 |
| `extraTestDirs` | string[] | `[]` | 追加 test 层目录名,只追加 |
| `extraDevServerWords` | string[] | `[]` | 追加长驻命令头,只追加 |
| `extraDevRunArgs` | string[] | `[]` | 追加 run 脚本名,只追加 |
| `extraSecretPatterns` | string[] | `[]` | 追加密钥路径正则片段(卡片每行一条;非法片段跳过并告警;病态模式的风险由配置者自负,见「已知边界」),只追加、无白名单 |
| `refSearchTools` | string[] | `["grep","glob","zg_search"]` | 哪些检索工具算"引用探查"凭证;清空即任何检索都不计入。给模型的指令只点这台宿主上真注册着的名字 |
| `refSearchStrictTools` | string[] | `["zg_search"]` | 上表里改走"root + 相对路径"严格口径的子集 |
**本包对危险命令与灾难目标没有任何白名单。** 四份 `extra*` 词表与 `refSearchTools` 放宽的是**检测面与证据来源**,不是权限:往 `refSearchTools` 里加一个名字,是让事实门接受来自那个工具的引用证据,于是它更容易满足,而不是更紧。命令/目标类规则误拦的出路是三条:临时关 `enabled`(或只关 `factGateEnabled`)、靠 `maxDenies` 配额放行、或按拒绝文案改写命令形态。事实门误拦时,把实际在用的检索工具名加进 `refSearchTools` 是第四条配置级出路。
上表里只有 `preflightEnabled` 在卡上没有控件:它标了即时生效,但设置条目以 `auto: false` 注册表单。改它要走设置行的 `config:`。
##
四个权限预设各拦什么
宿主的预设把沙箱模式与审批策略捆在一起,而官方沙箱只管**文件效应**:网络与进程面不在它的词汇表里,最紧的那一档也只是拒写、读一律放行。本插件补的正是那两面——而"写"类判据在沙箱管得住时让给它。
| 规则族 | 仅可查看 | 工作区内修改 | 完全权限 | 自动审查 |
|---|---|---|---|---|
| `noVerify`(git 钩子绕过) | deny | deny | **deny** | **deny** |
| `rmrf`(包住可写根 / 家目录 / 根) | allow¹ | deny | allow | **deny** |
| `rmrf`(沙箱完全覆盖这次目标) | allow | allow | allow | allow |
| `pipeShell`(下载即执行) | ask | **deny** | allow | **deny** |
| `devServer`(长驻服务) | allow² | allow² | allow² | allow² |
| `nestedShellUnconfirmed`(认不准) | ask | allow | allow | allow |
| git 破坏族 | ask | ask | allow | allow |
| 基础设施破坏族 | ask | ask | allow | allow |
¹ 只读档拒一切写,所以不存在"部分删除成功"——每个目标都被完全覆盖,整族让给宿主;宿主会给出官方拒绝标记与 `sandbox_permissions` 升级路径。
² 宿主自带的协作式超时与 `promoteOnTimeout` 已经管住长驻命令,这条规则不再提供安全属性,故整条让给宿主。
- **完全权限 = 零兜底,但仅限"问不了"的那一支,且有一处例外。** 官方预设把这一档绑死 `approval: never`,用户选它就是"不要拦、不要问":命中只进设置卡的统计,模型侧一个字都不改;例外是 `noVerify`,它仍然拒绝——绕 git 钩子与沙箱无关,整档放行会让它成为唯一的绕过口。而 `danger-full-access + ask`(自定义组合,或没装预设服务时的默认)**还能问**,那一支沿用工作区档的处置——沙箱什么都不拦却把动作静默放行,方向正好相反。
- **自动审查留三条确定性硬拦**(`rmrf`、`pipeShell` 与 `noVerify`)。reviewer 是一个 LLM,它的 allow 不等于用户批准,而这一档沙箱什么都不拦;`noVerify` 与其余各列一样拒绝——绕 git 钩子与沙箱无关,不交给 reviewer 决定。其余**全部**交给 reviewer,避免同一动作问两次。
- **一条硬规则:「能不能问」决定 `ask` 能不能落地。** `never` 在**任何应答者之前**就确定性返回 `rejected`(理由还会被宿主换成它自己的固定串),所以本该 `ask` 的规则在这一档由我们自己收口成 `deny`,并附一段"这档问不了、换哪一档才能问",不让模型只拿到一句"不许"。
- **读不到沙箱模式时**(没有可约束的执行器,或服务形状不符)走一条保守列:写类判据照拦,"认不准"照问。它**刻意不复用**工作区档那一列——那一列依赖"沙箱会拦住一部分"这个前提,而这里没有这个前提。
- 类型让两条性质**结构性**成立:忘定档 = 编译错误;一枚键两属不可能。后者是要紧的:`tools.guard` 是同步谓词而审批是异步的,且宿主在审批放行之后**还会再跑一次 guard**——"两处都认领"的表现就是**用户点了允许之后还是被拒**。
### 与沙箱的让路,以及为什么只有 `rmrf` 有
- 让路只在四件事同时成立时发生:执行器自报在拦、**它自报的默认模式本身也在拦**、解析出的模式不是完全权限、且双向包含性都不成立——目标既不能包住可写根,也不能落在可写根之内。
- 包含性判定读的可写根表由四部分组成:工作区根、**执行器显式授予的每个额外可写根**(`sandboxPolicy.resolve()` 交回的 `additionalWritableRoots`)、`/tmp` 与平台临时目录。根表里只要有一枚读不出来,整份表就按不可信处理、完全不让路:只丢掉那一枚会让它底下的目标显得被完全覆盖,从而放行一段其实会真实删完的删除。宿主不报这一位时按「没有额外授予」处理。
- 最后一条是刻意收紧的。`workspace-write` 的语义是*允许*改工作区内的文件,于是 `rm -rf ~`(工作区在 `~` 下)会**先删光工作区内容**、再在边界外被拒——沙箱是路径白名单,**不提供原子性**。所以 `~`、`/`、`.`、`..` 一律不让路,只有 `rm -rf /etc/foo`、`rm -rf ~/Downloads` 这类才让。
- `secretPath` 完全不开让路:本地与沙箱两套文件系统后端是**互斥装配**,"装的是哪个"没有可读的口子,按本仓纪律**不猜**。代价也不对称——误拦只是让用户去终端改,漏拦是凭据外泄且难以发现。
##
已知边界
按类登记,每类各钉一条现状用例;完整逐条理由写在那批测试旁边,不在这里。
- **`cmd` 当段头判不到。** `cmd.exe /c rd /s /q C:\Windows` 及其去 `.exe`、带路径前缀的写法仍逃逸:`cmd` 既不在删除命令词表、也不在下钻名单里,而它的引号规则(`%VAR%`、`^` 续行、`&` 不分段)离 POSIX 太远,拿 POSIX 词法器读它的 `/c` 体会新开一批误拦面。**PowerShell 家族已收**:`pwsh` / `powershell` 的 `-c` / `-Command` 体按 join 语义取出、逐段判,并在体内强制开 Windows 字形轨道。点了 `-File` / `-EncodedCommand` 而没有取体旗标 ⇒ 要跑的东西注定不在输入里,升格为需确认。
- **干跑修饰不进判据**(`Remove-Item -WhatIf -Recurse ~` 照拦)。`-WhatIf` / `-Confirm` 只是让 pwsh 打印将做什么;把干跑算成一类可豁免形态是一个还没人拍板的策略决定,所以现状按现状钉住。
- **裸 `~user`(词尾无分隔符)不收。** `rm -rf ~root` 展开成什么取决于本机账号表,而本闸既不碰文件系统也不查用户。**带分隔符的 `~user/…` 已收**。
- **PATH 上的解释器跑代码串一律需确认。** `python3 -c "print(1)"` 是良性的,也仍要确认——这是"看不见就不静默放行"的定价。
- **`bash` 重定向写入不在事实门覆盖面。** 门禁挂在 `edit` / `write` 两类工具上;`printf > f`、`tee`、`cat >`、`sed -i` 绕过"先取证"这条。纳入它要解析 shell 重定向与就地编辑器。
- **少数 `ask` 档形态是盲问。** 裸 `git checkout
数据与隐私
| 口径 | 内容 |
|---|---|
| 不写盘、不联网 | 判定链没有任何文件系统写入与网络请求;输出面只有 `console.*` 告警与回给模型的文案。**唯一**的例外是下面那趟 `ask` 档预演 |
| 子进程调用(只有这一处) | `ask` 档命中时,按写死的 `BLAST_COMMANDS` 跑一条**只读命令**,在会话 cwd 里、经宿主的 `ctx.shell`,因此与 bash 工具走同一套沙箱与凭据擦洗环境。`git status` 用 `--no-optional-locks` 抑制可选锁与索引刷新,但仍按会话解析出的 `sandboxPolicy` 执行。输出收紧后按每门语言接进审批请求的 `reason` 与 `displayReason`。任何失败(执行器缺席、仓库目标不确定、策略解析失败、非零退出、超时、取消、宿主截断输出、形状不符)一律退回盲问,不重试、不换命令。命令表写死、**不插值任何模型输入**;原命令只用于目标/策略的跳过判据 |
| 读什么 | 会话事件历史里的工具台账(主路径:守卫里同步读的投影单元;回退是一次全量事件扫描,判定逐字相同);目标与层目录的 `statSync`;预检写面对每枚声明目标的一次路径解析与一次正文读取;内容自证对最近一次成功编辑目标的一次回读(超过 1 MiB 的文件不回读,按「不知」处理);家目录取 `os.homedir()` |
| 记什么 | 全部在进程内存里——事实门分片、判定台账、以及卡片读的那几格计数——宿主退出即消失,dsh 数据目录下不留本包的文件 |
| 出域的有两处:`judge-cost` 可关,拒绝上报没有独立开关 | 装了 lesson-loop 时,`report` 载荷含 cwd、session id、命中命令原文或目标路径与拒绝文案,另带 `judgeMs` 与(密钥面命中时的)目标解析形清单。此外每次判定还有一条 `judge-cost` pass,载荷只有面名、毫秒、字符数三枚数值,不含命令原文与路径。**只有 `judge-cost` 那条 pass 受 `judgeStats` 管**——拒绝上报里的 `judgeMs` / `judgeLength` / `resolvedPaths` 是无条件发送的 |
##
常见问题
| 问题 | 怎么办 |
|---|---|
| 拦错了合法命令。 | 本包没有白名单,所以只有「设置项」那三条路:临时关、走配额放行、或按拒绝文案改写命令形态。 |
| 事实门一直不放行。 | 它只认成功的动作。照消息把检索起点指向项目根这类祖先目录;用了别的检索插件就把工具名加进 `refSearchTools`;实在取不到证就走配额放行或临时关 `factGateEnabled`。 |
| 它什么都没做。 | 先看设置页那一行,再用 `dsh --profile web --dump-config` 看本包的层**与 stderr**。宿主需在支持的版本线上。缺 `tools` 服务意味着条目从未激活——启动诊断会点名它。 |
| 怎么知道这个拒绝是本包给的? | 拒绝文案点明规则族与一条出路,并作为那次工具调用的错误结果出现。规则从不改写或丢弃调用;它要么拒绝,要么交出一份审批请求。 |
##
开发
```sh
pnpm install
pnpm run check # typecheck → lint → client 构建 → host 构建 → 类型 → 测试 → 格式
pnpm test # vitest,带覆盖率
```
- `test/` 分三层,分开的理由是**桩测不到的那一类错**:把宿主的形状猜错,判定却照样自洽。
- **单元**层用手写 `ctx` 桩:判据本身——词表、档位、缺项、状态机、文案。
- **集成**层用官方 testkit 的**真 `ToolRuntime`**,夹具真读真写,直接经 `ctx.tools.execute` 派发:瀑布顺序与物化——pre-execute 先于 guard、deny 变成 `isError` 结果、`additionalContexts` 与别的插件并存。
- **点对点**层用**真 agent loop 与真会话存储**,只有模型是脚本:A 面的引用面出现在**决定那次编辑的那一条请求**里,以及密钥正文在任何一次请求里都不出现。
- 后两层的分工是测出来的事实,不是偏好:直接执行一次工具不写会话日志,所以"模型自己读过/搜过"这类凭证只能在点对点层成立。
- 覆盖率四项阈值都是 **100%**,所以新增分支必须有能走到它的用例。
- 断言用的判别标记必须跳过**共用前缀**——行动清单、当场递证据、随读取附上三条文案开头相同,所以"取首词当标记"会让两条互斥的断言同时为真。取法统一收在一处。
##
许可
MIT。源码在 [github.com/JayYuen666/dsh-danger-guard](https://github.com/JayYuen666/dsh-danger-guard)。
事实门与命令规则是**机制层面**移植自 ECC(MIT,© Affaan Mustafa)——[`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md) 记录了上游文件、本包做了哪些改动,以及逐项比对的结果(未搬运上游的代码、词表或文案)。