# dsh-perm-gate

- [English README](./README.md)
- [中文 README](./README.zh.md)
- [日本語 README](./README.ja.md)
- [한국어 README](./README.ko.md)
- [Installation guide](./INSTALL.md)
- [中文安装指南](./INSTALL.zh.md)
- [日本語インストールガイド](./INSTALL.ja.md)
- [한국어 설치 안내](./INSTALL.ko.md)
- [Changelog](./CHANGELOG.md)
- [日本語 changelog](./CHANGELOG.ja.md)
- [한국어 changelog](./CHANGELOG.ko.md)

> **兼容性说明：** v2.0.0 自带 `ja` / `ko` 字典，但官方 DSH 的 `LocaleRuntime`
> 只暴露 `zh` / `en`（`LOCALE_IDS = ["zh", "en"]`）。在原版 DSH 上选择 `ja` / `ko`
> 会报 `locale "<id>" is not registered`。请使用更新了 `LOCALE_IDS`
> （locale-settings.ts）与 `LOCALES` 标签（client/index.ts）的 DSH fork 并重新构建。

> **▼ DSH 版本适配**
>
> 两个 DSH 版本线从两个长期分支分别维护，各有一套版本号系列、`engines.dsh`
> 和 npm 分发标签（[发布布局](./RELEASING.md)）：
>
> | DSH 版本 | 分支 | 版本号 | npm 标签 |
> | --- | --- | --- | --- |
> | 0.1.0-rc.7 ~ 0.1.1-rc.x | `legacy` | `1.x` | `@legacy` |
> | 0.1.2-alpha.1+（含 0.1.5-rc.2） | `main` | `2.x` | `@latest` / `@dsh-0.1.2` （`@2.x` 是范围） |
>
> 版本序列号跟的是 **DSH 线**（`1.x` = DSH ≤ 0.1.1，`2.x` = DSH 0.1.2+），两条大版本互
> 相隔离：锁在 `^1.x` 的安装绝不会解析到 `2.x`，反之亦然。`engines.dsh` 表达同样的
> 分界，但 DSH 从不读取它——真正把旧 DSH 钉在 `1.x` 上的是版本范围与 dist-tag。
>
> `@deepseek-ai/dsh-client-runtime` 在 `0.1.2-alpha.1` 中已被**移除**——不仅仅是更名。
> `legacy` 线仍通过它访问 `ctx.slots`；`main` 从
> `@deepseek-ai/dsh-client-ui-renderer/client` 获得相同的声明。两处版本敏感
> 接缝通过**能力探测**处理，而非版本号检查：（1）设置注册使用 `register`，两线
> 都存在（`installSection` 是新增项，不是替代）；（2）`effectivePolicy` 在两
> 线上都是 user-approval 服务的**私有**方法，因此通过 `typeof` 探测读取，缺失
> 或抛错时降级为「策略未知」。

版本 **2.4.1** —— 变更见 [Changelog](./CHANGELOG.md)。

一个**单一自足、确定性优先、fail-closed** 的 DeepSeek Harness 权限门插件。

对每个工具调用按固定优先级链裁决：

| 阶段 | 决策 | 含义 |
| ---- | ---- | ---- |
| **P0** | `deny` | 确定性硬拒：凭据材料 / 受保护路径改写 / 危险 shell |
| **P1** | `allow` | 精确、有界的**会话放行** grant |
| **P2** | `deny/allow/ask` | 静态规则链：黑名单优先，其次 allow，再 ask |
| **P3** | `allow/deny/ask` | 可选 LLM 语义分类器（默认**关闭**） |
| **P4** | `ask` | 官方 approval seam |

严格 fail-closed：P0 永不因 grant / 规则 / 分类器 / 人工而放行。

<!-- host-compat:begin — generated by scripts/sync-hosts.mjs from scripts/hosts.mjs; DO NOT EDIT -->
- **支持的 DSH 宿主：** `0.1.0-rc.2` `0.1.0-rc.3` `0.1.0-rc.6` `0.1.0-rc.7` `0.1.0-rc.8` `0.1.1-rc.1` `0.1.1-rc.2` `0.1.2-rc.1` `0.1.5-rc.1` `0.1.5-rc.2` `0.1.5-rc.3` `0.1.7-rc.1` `0.1.7-rc.2` `0.2.0-rc.1` `0.2.0-rc.2` `0.2.1-rc.1` `0.2.1-rc.2` `0.2.1-rc.3` `0.2.1-rc.4` `0.2.1-rc.5`（覆盖 0.1.0 / 0.1.1 / 0.1.2 / 0.1.5 / 0.1.7 / 0.2.0 / 0.2.1 七条线共 20 个 rc）
- **运行时已验证：** `0.2.0-rc.2`（证据见 `.compat-results/`）
<!-- host-compat:end -->

## 特性

- **命令白/黑名单** — 基于 **argv 分解**匹配（非裸字符串），递归下钻 `sh -c`/`bash -c`、识别管道、重定向目标、递归/强制（`rm -rf`）。
- **deny 优先** — 命中黑名单即拒绝，胜过任何 allow。
- **凭据类型阶梯** — 七类凭据（`ssh`、`cloud`、`package`、`gitcreds`、`env`、`cli`、`db`）各自可选 `deny` / `ask`（默认）/ `readonly` / `readwrite` 四档；档位状态存于规则文档的 `[deny-rule:cred.<family>]` 行（分区 + `enabled`）与成对的 `credWriteExemptFamilies` 清单，设置卡一次 update 成对写回，无镜像、无半态。`readwrite` 是唯一会退让部分 P0 的档位（仅凭据 store 的**写**判据，npm login 类工作流）且需卡内二次确认；根盘、受保护路径、`.dsh`、外发在任何档位恒拦。
- **保护分支强推开关** — `allowProtectedBranchPush`（默认关）保留 P0 对保护分支 `git push --force/--force-with-lease/--delete` 的地板；开启后地板让位、落入预设 `git.push-force` 的逐次人工确认，并同步豁免黑名单三条扁平 push 词。
- **会话放行** — 精确的 `(工具, 规范化 fingerprint)` grant，带 `TTL` + `maxUses`；换目标绝不复用。子代理继承但不可自授。
- **纯函数规则引擎** — glob/regex 编译 + ReDoS 上限、坏规则 loud fail、按源内容哈希缓存。
- **审计** — 每次决策写为 `{ignorable:true}` 事件并带 `callId`；模型可见理由与记录一致。
- **自动审查档位**（机器值 `permissive`）——一个**独立审批模式**（区别于只读、完全权限与白名单档），既不是"自动审批"，也不授予泛化权限。前端只暴露**一个开关**（`permissive`），后台四个审批策略**可组合**、由插件设置决定——仍对 P0 保持 fail-closed。权限下拉框与设置行都按产品名「自动审查」显示；图标见下文（内置档自带，插件档需补丁）。
- **沙箱提权自动答复**（`trustEscalation`）— 沙箱提权是从 shell / pwsh / edit 工具**体内部**（`tools/pre-execute` 之后）发出的，所以门禁从未见过它，一个它自动放行的调用仍会弹出确认。开启后，门禁以 `callId` 精确匹配已放行调用并直接答复。

## 安装

需要先安装 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)。

```sh
dsh plugin --profile web add dsh-perm-gate
```

完整的安装、升级、迁移与排查步骤见[中文安装指南](./INSTALL.zh.md)（另有
[English](./INSTALL.md) / [日本語](./INSTALL.ja.md) / [한국어](./INSTALL.ko.md)）。

## 配置

### AppContainer 沙盒（Windows；beta）

`dsh-perm-gate` 可以把门控放行的 shell 命令放进 **Windows AppContainer** 里执行，而不是裸交还
宿主。容器 token **不带任何网络能力**，非回环出网被 Windows 防火墙（WFP）直接封死——不是环境
变量那种可绕过的软约束。容器 SID 由 OS 从固定名 `dsh-perm-gate.ac` 派生，因此
`CheckNetIsolation LoopbackExempt`（放行容器访问 `127.0.0.1`）是**每台机器一次**的步骤，不是每次
会话都要做出网因此**只能**走网络代理：

- `sandboxEnabled`（默认 `false`）——总开关。开启后每条**自动放行**的 shell 调用会被重写成启动器
  调用；人工批准的调用不在沙盒范围（用户已明确知情）。启动器无法编译、或 OS 拒绝容器启动时，
  该调用被**拒绝**（fail-closed），理由带 `sandbox: …` 前缀——绝不退回裸执行。
- `sandboxMode`——`workspace-write`（默认：工作区 Modify + 私有 temp FullControl，系统目录靠
  ALL APPLICATION PACKAGES 默认 ACE 保持可读）或 `read-only`。
- `sandboxProxy`——**代理参数**：空（默认）跟随内置过滤代理实际绑定的 `127.0.0.1:<port>`（需
  `networkEnabled`）；显式 URL 必须绑定回环（容器只可达回环），出网对象的过滤由该代理完成。
- `sandboxLoopback`——`exempt`（默认：尝试一次性豁免，被拒时给出精确提权命令）或 `off`。

启动器是一小段 C# 源码（`assets/appcontainer-launcher.cs`），首次使用时由 Windows 全系自带的
.NET Framework 编译器（`csc.exe`）按需编译；插件本体保持零原生依赖。真机端到端验证：

```
node scripts/smoke-appcontainer.mjs
```

已知边界：在经典 AppContainer 创建 API 已被移除的 Windows 11 25H2+ 新构建上（26200 实测），
mxc 替代尚未暴露（26600+），启动器在内核步 fail-closed；此时沙盒调用一律明确拒绝，不裸跑。
Windows 10 / 11 ≤ 24H2 走文档化的经典路径。

`cordis.yml`：

```yaml
- id: dsh-perm-gate
  name: dsh-perm-gate
  config:
    rulesFile: ./permissions.yaml   # 可选；默认 $DSH_HOME/perm-gate/rules.yml
    dshHome: $DSH_HOME
    defaultAction: ask
    gatePresets: [permissive, permissive-full]   # 门禁生效的档位（默认值）
    sessionSweep: true              # 每小时清理已归档/已删除会话的门禁数据
```

### 会话清扫（session sweep）

插件启动时及每小时读取 DSH 的工作区存储（`$DSH_HOME/storages/workspace.json`，只读），
对门禁持有授权链数据的每个会话做归类。已被 DSH 归档（`global.archivedSessionIds`）
或彻底不存在的会话，其决策事件会从 `$DSH_HOME/perm-gate/events.jsonl` 中移除，
其决策前文件快照会从 `$DSH_HOME/perm-gate/snapshots/` 中删除——宿主已视为消失的数据，
审查页也不再保留其历史。活跃会话不受影响；无法归属的行（空 sessionId）永不删除；
任何失败都 fail-open：本轮跳过，一小时后重试。设 `sessionSweep: false` 关闭；
`workspaceStoreFile` 可覆盖存储路径。恢复归档会话不会找回已被清扫的历史。

规则示例：见 [examples/permissions.example.yaml](./examples/permissions.example.yaml)。

### 网络策略（可选开启）

本地 HTTP/CONNECT 代理，用**同一份规则文件**审查 **shell 子进程**的出站流量，并对无规则
覆盖的目标提供审批通道。**默认关闭** —— 开启后会绑定回环端口并改写子进程的代理环境变量，
因此绝不隐式启用。

```yaml
- id: dsh-perm-gate
  config:
    networkEnabled: false          # 总开关（默认 false）
    networkMode: whitelist         # deny-all | whitelist | allow-all
    networkUnlisted: ask           # ask | deny —— 未列出目标的处理方式
    networkUnattributed: allow     # allow | deny —— 无 shell 归属的流量
    networkInjectEnv: true         # 为子进程改写 HTTP(S)_PROXY / ALL_PROXY
    networkAskTimeoutMs: 120000    # 审批等待上限，超时按拒绝处理
    networkGrantTtlMs: 1800000     # 一次批准的会话有效期
```

**分层行为**：没有 allow 规则，任何目标都出不去。未列出的目标会升级到交互审批，挂在该
shell 命令的会话上；批准后该目标在本次会话内放行。**`deny` 规则永不升级为审批** —— 审批
只能为「无规则禁止的目标」拓宽可达性，永远不能推翻一条说「不」的规则。

**边界 —— 依赖它之前请先读这段**：代理是**协作式**策略层，不是强制边界。它只能看到
**愿意读代理环境变量**的客户端的流量。

| 客户端 | 能拦吗 |
|--------|--------|
| `curl`、`wget`、`git`、Go `net/http`、Python `requests` | ✅ |
| **Node.js `http` / `https` / `fetch`** | ❌ **直连，代理看不到** |
| Java（未加 `-D` 代理参数）、.NET `HttpClient` | ❌ |
| 原始 socket、自写 TCP | ❌ |
| DNS、QUIC/HTTP3、非 HTTP 协议 | ❌ |
| 连接字面 IP | ❌ |

因此 `node -e "require('http').get('http://host/')"` 这类命令**不会被拦截**。请把它当作
「防误操作的护栏 + 声明意图的地方」，而不是密闭沙箱。

DSH **自身**的网络流量 —— 内建网络工具与 LLM 传输 —— 刻意不管：这些连接不带 shell 归属，
而 `networkUnattributed: allow`（默认）会直接放行。审查它们会导致宿主**把自己拦死**，
那比漏拦严重得多。只有在你确定宿主的客户端不读代理环境变量时，才考虑改成 `deny`。

实时状态查询：`GET /api/dsh-perm-gate/network`（模式 / 绑定 / 端口 / 代理存活 / 环境注入
状态 / 阻断计数 / 最近阻断）。

## 自动审查档位（机器值 `permissive`）

自动审查是权限下拉框里一个**独立审批档**，与只读 / 工作区内修改 / 完全权限 / 白名单平行。
它不是泛化的"自动审批"、也不授予泛化权限：只会在人类/LLM 接缝**之前**收窄或放宽决策，
P0 硬拒绝在**本门禁自身的档位作用域内**始终单调且不可协商。

> **P0 是档位作用域内的，不是全局的。** 门禁只在会话权限档位属于 `gatePresets`
> （默认 `permissive` / `permissive-full`）时生效。其他档位 —— 只读、工作区内修改、完全权限
> —— 下**整个门禁停用，包括 P0 硬拒绝**，因为该档位自身的策略接管了这个会话。这是刻意设计
> （见配置表的 `gatePresets`），但也就意味着「P0 不可协商」成立于**门禁的档位之内**，而非所有档位。
> 停用**不是静默的**：每次会话档位切换会记录一条 `stand-down` 事件，浏览器在输入框上方常驻一条
> **GATE OFF** 提示条。把 `gatePresets` 设为 `['*']` 可让 P0 重新变成全局。

**提供两个变体** —— 因为预设的 `sandbox` 与 `approval` 是两根**独立**旋钮，把它们绑死会逼出
一个糟糕的取舍：

| 下拉框名称 | 机器值 | sandbox | approval |
|-----------|--------|---------|----------|
| 自动审查 | `permissive` | `workspace-write` | `ask` |
| 自动审查（高权限） | `permissive-full` | `danger-full-access` | `ask` |

普通档保留内置文件沙箱。而那个沙箱**同时**拒绝子进程启动所需的命名管道 —— 所以 `git clone`、
MSYS2/Cygwin 的 `sh.exe`、ConPTY 都会以 `Win32 error 5` / `couldn't create signal pipe` 失败。
又因为门禁**只在 `gatePresets` 列出的档位里生效**，想用门禁就必须接受这个限制。
「自动审查（高权限）」解开了这个耦合：**审批行为完全相同，但不限制文件沙箱** —— 档位自带的描述已把代价
写明：流程更顺畅、审批仍逐次生效，但**不再有系统沙箱兜底**。两者都在默认
`gatePresets` 里，任选其一都能获得完整的 P0–P4 链路 —— 门禁只读预设的**名字**，从不读 sandbox 模式。

下拉框里的名字是**宿主提供的产品名**，不是逐语言的字典项：DSH 对插件档位在**两个**权限界面上
（通用设置默认档行、输入栏权限选择器）都原样渲染补丁里的 `name:`，只给三个内置档提供自己的本地化
标签，因此 `cordis.patch.yml` 直接写中文名，对所有会话一致。

**图标是另一回事。** 输入栏的图标表是闭合的，表自己的注释写明了规则：*host-configured names
outside the design set get none*。`permissive` 是内置值，所以「自动审查」本来就有盾+眼图标；
「自动审查（高权限）」能拿到同一个图标，靠的是 `npx dsh-perm-gate-patch-glyph` 往那张表里
加了一项。该补丁改的是**宿主**包，每次 DSH 升级都会丢 —— 见
[DSH 升级后：重打输入区图标补丁](./INSTALL.zh.md)。

`cordis.yml`：

```yaml
- id: dsh-perm-gate
  name: dsh-perm-gate
  config:
    rulesFile: ./permissions.yaml
    defaultAction: ask
    permissive: true            # 前端唯一的开关（启用独立档）
    permissiveStrategies:        # 后台策略，可组合
      trustAutoAllow: true       # 作用域内安全操作自动放行；危险/未知转 ask
      alwaysConfirm: false       # 一律逐次 ask；允许控件附带重复允许/迁白名单按钮
      trustEscalation: true      # 门禁已放行的调用，其自身的沙箱提权免确认
      llmAssist: false           # 先由 LLM 分类裁决；ask/无分类器时回退到人工
    yolo: false                  # 无人值守：永不问人，危险才拦、其余放行
    rulesFallback: false         # 无可用审查模型 => 只按确定性规则网裁定
    scriptReview: true           # 把调用所跑脚本的正文随审查一并附上
    credentialPaths: []          # 长尾凭据路径，按 `.ssh` 同档保护
    denyKeywordsExtra: []        # 追加黑名单词（不替换预置表）
    extraContentArgKeys: []      # 哪些参数是文档而非操作
    extraOperationArgKeys: []    # 哪些参数确实是会被执行的代码（撞名时本表胜）
    safenetPass: true            # permissive-mech 档的实现存在（机械接管 ask）
    safenetAsk: false            # permissive-snet 档的实现存在（机械过滤，仍然问人）
```
三颗开关，每颗只管插件自己贡献给权限下拉的一个预设——所以一颗开关只改它那一档的行为，不会串到
别的档；把实现关掉，那一档就退回普通人工流，而不是偷偷变成另一个档。

| 开关 | 档 | 谁定 ask | 问人 | 叫模型 |
| --- | --- | --- | --- | --- |
| `permissive` | `permissive` / `permissive-full` | 机械先筛 + 模型精化，剩下交给人 | 会 | 由 `llmAssist` 决定 |
| `safenetAsk` | `permissive-snet` | 交给人，并列出机械疑点 | **会** | **一次都不叫** |
| `safenetPass` | `permissive-mech` | 机械独判：点名的拦+回喂，其余放行 | 不会 | **一次都不叫** |
| `yolo` | `permissive-yolo` | 模型替人裁决：risky 拦+回喂，其余放行 | 永不 | 每个残局 |

`safenetPass` 就是 `approval: never` 的「有审查版」（那一档无脑直通、没人判定），也是 yolo 拿掉
模型后的劣化形态。`yolo` 按定义就是带模型的那一档：模型不在场时它仍然不问人、按机械处置，并把
记录写成 `yolo-allow-no-reviewer`，好让审批历史不会被误读成"模型答过"。五档都由插件自带 patch
发布，装好就在下拉里，不需要手改宿主配置。不在 `gatePresets` 里的预设走不到这一步：那里整个门禁
停用，本来就没有需要回答的确认。

(llmAssist 的真实接收 LLM 在设置页填 `classifierEndpoint` / `classifierModel`，OpenAI 兼容的自定义 API
均可。设置页可选择接收来源：**自定义 API**（任何 OpenAI 兼容端点，内置小米 MiMo `https://api.xiaomimimo.com/v1` 等预设）或**宿主模型组**（复用 DSH 会话已配置的 `llm` 服务与当前模型组，可用 `classifierProvider` / `classifierModel` 覆盖）；并提供**健康测试**按钮，一键验证接收 LLM 的连通性与延迟。)

`trustAutoAllow` 是中间档基线（rule-allow 自动放行）。`alwaysConfirm` 让每次越界都走审批面板，其
「允许控件」含两个扩展按钮：**本会话重复允许该类**（会话限次 grant，`approveRepeat`）与
**允许所有类型**（把命令词持久写进 `permissions.yaml` 的 allow 白名单，`approveAllowEverywhere`）。
`llmAssist` 调用配置的真实 LLM（任意 OpenAI 兼容 API）自动裁决 `ask`，结果不确定/出错时回退人工
接缝——始终 fail-closed。`trustEscalation`（档位开启时默认开）答复门禁已放行的调用在其工具体内
提出的 `sandbox_permissions` 提权；见下文。`permissive` 关闭时，门禁行为与之前完全一致。

### 无人值守档位，以及如何保护预置表认不出的凭据

三个开关改变“谁来定”，七个键改变“能看见什么”。它们全部 volatile：设置卡改完下一个工具调用即生效，无需重载。

- `yolo`（负责 `permissive-yolo` 那一档）— 永不弹人工确认。危险 ask（不可逆 / 不可沉淀 / 载荷读不懂 / 审查模型报硬风险）被拦截，理由以 `工具审核意见` 消息经 **steer 车道**（`next-step`，贴着被拒的工具结果、在同一回合的下一步送达）回喂主模型，不占用户输入队列；其余照常执行。审查模型不在场时它不静默放行：由机械审查器裁定，并把事件记为 `yolo-allow-no-reviewer`——一档既然声称有模型，就不能把没答上的模型报成答过了。
- `rulesFallback` — 降级审查。仅当**没有审查模型**（未配置，或端点未能给出裁决）时生效：改由确定性规则网裁定，不再把调用挂在没人应答的确认框上。模型正常应答时行为不变。它只作用于普通的自动审查档（`permissive` / `permissive-full`）；那三颗各自拥有一档的开关已经写明由谁回答，不会被它降级。
- `scriptReview`（默认开）— 调用跑脚本时读取它的正文（有界、绝不执行），同时交给审查模型、人工卡片与规则网：只在 `clean-home.sh` 里看到的删除从此按代码判定。正文全读到就不再是“读不懂的载荷”，从而脱离 U 档。关掉可不让文件内容离开本机，代价是工作区外脚本回到“看不清、问人”。

“什么算凭据”的长尾由你扩展，但每张表**只往内置表上追加**，不提供替换能力——漏写 `content` 或 `plan` 会静默重现本门禁已经修过的那个 bug：

- `credentialPaths` — 路径 glob（双星跨目录、单星与问号限一段；无通配符时按整个路径片段匹配；`~/` 展开为你的 home；相对写法也在任意深度匹配）。列入的路径与 `.ssh` 同档：工作区外读取被拒、被 `write`/`edit` 改写被拒。该档是 P0（不可申诉、审查模型也放行不了），这正是它是配置项而非 `rules.yaml` 规则的原因：只读工具在规则链**之前**就被自动放行了，规则看不见这类调用。
- `denyKeywordsExtra` — 向当前生效的黑名单（预置或你替换后的表）**追加**词项，为公司 CLI 的吊销子命令或自有令牌前缀加一道确定性拦截，而不必把 50 条预置抄进 profile。
- `extraContentArgKeys` / `extraOperationArgKeys` — 哪些参数是文档、哪些是操作。前者阻止宿主工具的 `steps`/`notes` 正文被当成操作读（issue #2 就是这么发生的：一份诚实的计划写了 `rm -rf` 反而因“描述”被拒）；后者为 `workflowYaml` 这种“正文真的会被执行”的字段反向开口，且与前者撞名时本表获胜。把真正的文档字段写进来，就等于让“计划里提到 `rm -rf`”重新变成拒绝理由，所以默认关。

把 `command` 或 `file_path` 声明为文档并不能削弱门禁：被执行的命令与被改写的路径由各自的判据路径负责，不走这张表（已有断言测试钉住）。

### 沙箱提权：为何 `safe` 裁决仍会弹窗

一个工具调用可能触发**两个独立的审批**。门禁负责第一个——它的 `ask`，在 `tools/pre-execute`
瀑布上。第二个来自工具体内部的 `approveEscalation`，在 `tools/execute` 时刻，只要模型传了
`sandbox_permissions` + `justification`；此时 `tools/pre-execute` 已结算，门禁的放行从未到达它。
LLM 评定为 `safe` 且门禁自动放行的调用因此仍会弹出确认。

`trustEscalation` 填补这个缺口。门禁记住每个它正面向上放行的调用（以宿主 `callId` 为键，提权
请求会重复该值），并在本处自行答复 `allowed-once`。它仅在**全部满足**时适用：

- 自动审查档位开启且 `trustEscalation` 开启；
- 请求携带门禁放行的 `callId`，且工具名匹配；
- 原因为已知的提权，指明 `workspace-write` 或 `danger-full-access`。

其余所有情况——未知原因、不同的调用、门禁要求或拒绝的调用、`approval: never` 透传——都保持交给人
工，因此未来 DSH 措辞变更时 fail-closed。自动答复记录在事件流中
（`verdict: "escalation-auto"`，`mode: <目标模式>`）。关闭开关可使沙箱放宽保持人工审批，其余
自动放行不变。

### 权限下拉里可选档位

`cordis.patch.yml` 在 DSH 的 `permission.config.presets` 里新增了 `permissive` preset
（`sandbox: workspace-write`、`approval: ask`、名称 **自动审查**），位于工作区内修改与
完全权限之间。DSH 的 bundle patch 对这个 map 是**整表替换**而非逐键合并，所以该文件还必须重述三个内置档
（`read-only` / `workspace-write` / `danger-full-access`，取自
`@deepseek-ai/dsh-base/cordis.patch.yml`）；`test/patch-presets.spec.ts` 固定了这份键集合。因此会话权限
下拉里会出现「自动审查」这个**独立可选审批档**，而不是"auto-approval"档。

门禁**只在 `gatePresets` 列出的档位里生效**（默认 `['permissive', 'permissive-full']`，即本插件新增的
两个档位）。在其余任何档位
（Read Only、Workspace Write、Full access、`custom`）里，门禁的判定流程**完全不运行**：不放行、不弹审批、
不拒绝、不执行 P0 硬拒绝、不做黑名单关键词拦截，也不写审计事件——该档位自己的策略说了算。这正是重点所在：
`danger-full-access` 的定义就是"全权限、不弹审批"，用 ask 去覆盖它毫无意义（该档 `approval: never` 会让审批接缝
**在任何 answerer 运行之前**直接返回 `rejected`，被转发的 ask 只能得到 `the user rejected tool "..."`，面板根本不会
弹出），用硬拒绝去覆盖它则等于悄悄推翻用户选定的档位。`gatePresets: ['*']` 可让门禁重新全局生效（含硬拒绝层）；
在生效档位内，若会话生效的审批策略为 `never`，ask 仍会降级为放行。

### 在 UI 里可配置

该档位也可在运行时从 **设置 → 插件 → 自动审查** 调整（插件浏览器端渲染的
`settings.plugins.tab` 页面）：一个开关切换 `permissive`，四个开关编辑后台
`permissiveStrategies`。host 端 live 读取该命名空间，改动对下一条工具调用即时生效，无需重启。
这是一个独立审批类，**不是** DSH 的"auto-approval"档。

### 风险分级 llmAssist、裁决学习与事件流

开启 `llmAssist` 后，接收 LLM（自定义 OpenAI 兼容端点，或 DSH 宿主模型组——见上文）按结构化协议逐条评估 `ask`。**判定发生在门禁的 `tools/pre-execute` 瀑布内部、决策返回宿主之前**：`safe` 直接放行，审批面板根本不会出现；只有真正无法确定的判定才会弹到你面前。

- `safe` → 自动放行（审计来源为 `classifier`），不弹面板。
- `risky` + **硬风险类别**（`deletion`、`credential`、`remote`、`system`、`bulk`）→ **维持人工确认（ask）**。分类器**永不拒绝**：拒绝只属于确定性层（P0 硬拒绝、黑名单关键词、显式 `deny:` 规则），所以被误判的类别永远可协商，而不会变成无法申诉的封禁。硬类别与 `neutral` 仅保留一条关键区别：**永不进入学习**，因此反复确认也不可能把它沉淀成自动放行。
  （拿模型的判断当拒绝依据是实测出来的问题：一条无害的 `git commit -F …` 被判 `remote`，直接自动拒绝——没有面板、也没有可重试的授权入口。）
- `risky:neutral` → 若开启 `riskLearning`（设置卡片内，默认关闭），人工批准且真实执行的 neutral 风险会按 `tool|类别` 计数；计数达到 `riskThreshold`（默认 3）且新调用的操作指纹（命令词 + 目标基名）命中已确认样本时，**同一操作**自动放行。不同目标永不复用该放行。开启学习沉淀（`riskSediment`，默认开）后，满阈值 key 的确认样本会成为**确定性放行规则**：指纹精确命中即直接放行、无需再过 LLM——即使关闭 llmAssist 也继续生效；沉淀规则在设置卡片中可见、可管理（终止学习 / 删除样本）。
- 超时（`riskTimeoutMs`，默认 20s，重试 1 次）、传输失败与协议外输出均维持原 `ask`——门禁绝不猜测。

学习状态持久化在插件自有 JSON（`$DSH_HOME/perm-gate/learning.json` 或 `learningFile`），不写入你的 YAML 规则文件。每次决策都会追加到 `$DSH_HOME/perm-gate/events.jsonl`（或 `eventsFile`），并经 `GET /api/dsh-perm-gate/events?sessionId=&since=` 提供；浏览器端轮询该接口，在输入框上方以提示条展示最新决策（ask 常驻至下一条事件），并在对话视图的「审批记录」页签按时间倒序列出本会话的全部判定。

每次决策涉及的文件都会在改动落地前快照（每事件 ≤5 个文件、单文件 ≤256 KB）到 `$DSH_HOME/perm-gate/snapshots/`；「审批记录」页签中每个文件 chip 可点开行级改动对比（`GET /api/dsh-perm-gate/diff`），并可**撤销**该改动——向会话投递恢复指令（`POST /api/dsh-perm-gate/revert`）。快照管理条支持按会话或全量清理（`GET /api/dsh-perm-gate/snapshots-stats` / `POST /api/dsh-perm-gate/snapshots-clear`）。

转人工的 `ask` 会被跟踪到人工给出答复为止：一个**被动** `approval/request` 观察者记录封闭结果（`allowed-once` → **人工通过**、`rejected` → **人工拒绝**、`cancelled` → **人工取消**、`unavailable` → 拒绝，因为不存在审批通道）；当观察者无法关联该 ask 时（缺 `callId`、无 approval 服务、上游监听者短路），由 `tools/result` 兜底结算同一个 ask。人工通过会显示通过后的学习进度（`n`/阈值），通知条也会为三种终态分别打标。

插件还内置一份**预置黑名单关键词**（继承自 dsh-approval-gate 的 `DEFAULT_DENY_KEYWORDS`：
`rm -rf`、`push --force`、`drop table`、`mkfs`、`git reset --hard`、`docker system prune` 等），
调用文本命中任一关键词（大小写不敏感子串）即直接拒绝，且先于白名单 / 授权 / LLM。黑名单在设置
卡片中按列表查看与增删（预置条目带标签，可一键恢复预置）；未设置或为空时应用预置列表——黑名单
不会静默关闭。
「自动审查」与「自动审查（高权限）」在选择器里都画盾+眼图标 —— 前者来自 DSH 内置表，后者来自
安装指南里描述的那次宿主补丁。没有该补丁时，第二个档位在所有界面上都是纯文字；它的标签与门禁
不受影响。


## 凭据类型阶梯（`deny / ask / readonly / readwrite`）

凭据面过去是一条判据。现在是七族 × 四档——“读 `.env` 先问我”和“让 agent 再跑一次 `npm login`”
本来就是两种需求。

| 档位 | 文件工具直读 / 浏览 | 写与删 |
| --- | --- | --- |
| `deny` | 规则层硬拦 | P0 硬拦 |
| `ask`（默认） | 逐次转人裁 | P0 硬拦 |
| `readonly` | 静默放行 | P0 硬拦 |
| `readwrite` | 静默放行 | 凭据 store **写**判据退让（npm login 类） |

- **状态就住在插件自己的两个存储里**：规则文档中该族的 `[deny-rule:cred.<family>]` 行
（分区 + `enabled`）与成对的 `credWriteExemptFamilies` 清单。设置卡经
`GET/POST /api/dsh-perm-gate/cred` **一次 update** 成对写回。
- **读面**由“文件工具先问规则层”驱动：`decide()` 先问 ruleset，且只采纳**具名命中**；
未命中保持静默自动放行，弹窗预算不变。会话/协调类内部工具永不预询。
- **`readwrite` 只退让一条判据**——凭据 store 写屏。根盘/系统盘清扫、受保护破坏目标、
`.dsh`、外发与读地板在任何档位恒拦；应用前卡片要求二次确认。自定义 `credentialPaths`
无族归属、永不参与写豁免。
- 七行随预设表**版本 1** 发布：升级经 `presetRulesVersion` 水位只补缺失行，你撤销过的行永不复活。

```
SSH (~/.ssh)              deny  ask  [readonly] readwrite
Cloud (.aws/.gcloud/…)    deny  ask   readonly  readwrite
…
```

## CLI（独立 dry-run）

```sh
dsh-perm-gate --rules permissions.yaml --tool bash --args '{"command":"pnpm install"}'
dsh-perm-gate --rules permissions.yaml --list
```

## 开发

```sh
npm run typecheck
npm test
npm run build
```

## 许可证

[MIT](./LICENSE)