dsh-perm-gate 2.1.2 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.ja.md +108 -0
  2. package/CHANGELOG.ko.md +104 -0
  3. package/CHANGELOG.md +236 -0
  4. package/INSTALL.ja.md +41 -21
  5. package/INSTALL.ko.md +37 -19
  6. package/INSTALL.md +46 -23
  7. package/INSTALL.zh.md +33 -17
  8. package/README.ja.md +52 -12
  9. package/README.ko.md +53 -13
  10. package/README.md +68 -10
  11. package/README.zh.md +41 -10
  12. package/assets/allow1.png +0 -0
  13. package/assets/allow2.png +0 -0
  14. package/assets/gateoff.png +0 -0
  15. package/assets/llms.png +0 -0
  16. package/assets/permlist.png +0 -0
  17. package/assets/setting-head.png +0 -0
  18. package/assets/setting-rules.png +0 -0
  19. package/cordis.patch.yml +11 -8
  20. package/docs/baseline-guard.md +56 -0
  21. package/docs/plan-rules-to-settings.md +217 -0
  22. package/docs/rules-format.en.md +294 -0
  23. package/docs/rules-format.md +279 -0
  24. package/docs/ui-gap-analysis-and-branch-permissions.md +338 -0
  25. package/dsh.plugin.json +2 -2
  26. package/examples/permissions.example.yaml +16 -1
  27. package/lib/allowlist.d.ts +27 -0
  28. package/lib/allowlist.js +103 -1
  29. package/lib/cli.js +11 -14
  30. package/lib/client.js +484 -6
  31. package/lib/client.js.map +1 -1
  32. package/lib/command-dispatcher.d.ts +22 -0
  33. package/lib/command-dispatcher.js +97 -0
  34. package/lib/command-semantics.d.ts +62 -0
  35. package/lib/command-semantics.js +6 -0
  36. package/lib/compiler.d.ts +11 -0
  37. package/lib/compiler.js +16 -0
  38. package/lib/config.d.ts +73 -12
  39. package/lib/config.js +56 -14
  40. package/lib/dry-run.d.ts +87 -0
  41. package/lib/dry-run.js +110 -0
  42. package/lib/engine.js +52 -2
  43. package/lib/evaluate.js +76 -2
  44. package/lib/events.d.ts +56 -2
  45. package/lib/events.js +92 -0
  46. package/lib/index.js +106 -26
  47. package/lib/parsers/git.d.ts +43 -0
  48. package/lib/parsers/git.js +404 -0
  49. package/lib/parsers/shell-cmds.d.ts +32 -0
  50. package/lib/parsers/shell-cmds.js +293 -0
  51. package/lib/preset.d.ts +13 -0
  52. package/lib/preset.js +17 -0
  53. package/lib/risk.d.ts +17 -3
  54. package/lib/risk.js +17 -3
  55. package/lib/rule-chain.js +13 -22
  56. package/lib/rule-dims.d.ts +35 -0
  57. package/lib/rule-dims.js +36 -0
  58. package/lib/rule.d.ts +36 -1
  59. package/lib/rule.js +56 -8
  60. package/lib/rules-view.d.ts +52 -0
  61. package/lib/rules-view.js +126 -0
  62. package/lib/runtime.d.ts +121 -7
  63. package/lib/runtime.js +204 -25
  64. package/lib/shell.d.ts +14 -0
  65. package/lib/shell.js +17 -0
  66. package/package.json +19 -2
  67. package/scripts/patch-permission-glyph.mjs +225 -42
package/README.md CHANGED
@@ -26,7 +26,8 @@
26
26
  > | DSH version | Branch | Version | npm tag |
27
27
  > | --- | --- | --- | --- |
28
28
  > | 0.1.0-rc.7 ~ 0.1.1-rc.x | `legacy` | `1.x` | `@legacy` |
29
- > | 0.1.2-alpha.1+ (incl. 0.1.5-rc.2) | `main` | `2.x` | `@latest` / `@dsh-0.1.2` (`@2.x` is a range) |
29
+ > | 0.1.2-alpha.1 ~ 0.1.5-rc.0 | `main` | `2.x` | `@latest` / `@dsh-0.1.2` (`@2.x` is a range) |
30
+ > | 0.1.5-rc.1+ | `sync/0.1.5-from-main` (=`compat/0.1.5`) | `3.x` | GitHub ref install (no dist-tag yet) |
30
31
  >
31
32
  > The series number tracks the **DSH line** (`1.x` = DSH ≤ 0.1.1, `2.x` = DSH 0.1.2+),
32
33
  > and the majors fence each other: a `^1.x` install never resolves a `2.x` release and
@@ -42,7 +43,7 @@
42
43
  > **private** method of the user-approval service on both, so it is read behind a
43
44
  > `typeof` probe and degrades to “policy unknown” when absent or throwing.
44
45
 
45
- Version **2.1.2** — see the [Changelog](./CHANGELOG.md).
46
+ Version **2.6.1** — see the [Changelog](./CHANGELOG.md).
46
47
 
47
48
  A single, self-sufficient, deterministic-first, fail-closed permission gate for DeepSeek Harness.
48
49
 
@@ -104,6 +105,31 @@ grants and LLM. It is editable as a list in the settings card (preset entries ar
104
105
  one-click restore brings the preset back); unset or empty applies the preset — the blacklist
105
106
  never silently turns off.
106
107
 
108
+ ## Screenshots
109
+
110
+ **The settings card — the 自动审查 tier.** The front end exposes a single switch; the four back-end
111
+ strategies combine. `LlmAssist` can follow the session's current model or be pinned to a provider /
112
+ model, and carries its own health test and verdict learning:
113
+
114
+ ![Settings card — the 自动审查 tier](assets/setting-head.png)
115
+ ![LlmAssist — provider, model, API key](assets/llms.png)
116
+
117
+ **The rules the gate actually loaded**, read straight from the `dsh-perm-gate-rules` settings
118
+ namespace — the panel is read-only by design, so it can never edit what it shows:
119
+
120
+ ![Built-in rules, read-only](assets/setting-rules.png)
121
+
122
+ **Approval decision log** — every verdict in this session, newest first, with the reason the model saw:
123
+
124
+ ![Approval decision log](assets/permlist.png)
125
+
126
+ **Decision strips** — an auto-allow states its basis (a read-only tool, or an LLM `safe` verdict),
127
+ and a stand-down is never silent:
128
+
129
+ ![ALLOW strip — read-only internal tool](assets/allow1.png)
130
+ ![ALLOW strip — llm-assist risk: safe](assets/allow2.png)
131
+ ![GATE OFF strip — the gate stood down](assets/gateoff.png)
132
+
107
133
  ## Install
108
134
 
109
135
  Requires an existing [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) installation.
@@ -229,7 +255,16 @@ proxy liveness, env-injection state, block counters, recent blocks).
229
255
  自动审查 is an **independent approval tier** in the DSH permission picker, parallel to
230
256
  Read Only / Workspace Write / Full access / Whitelist. It is **not** generic "auto-approval" and
231
257
  never mints blanket authority: it only narrows or widens the seam *before* the human/LLM step
232
- while P0 hard-deny stays monotonic and non-negotiable.
258
+ while P0 hard-deny stays monotonic and non-negotiable **within the gate's own scope**.
259
+
260
+ > **P0 is scoped, not global.** The gate acts only while the session's permission preset is one
261
+ > of `gatePresets` (default `permissive` / `permissive-full`). Under any other preset — Read Only,
262
+ > Workspace Write, or Full access — the **entire** gate stands down, P0 hard-deny included,
263
+ > because the selected tier's own policy governs that session. This is deliberate (see
264
+ > `gatePresets` in the configuration table), but it means "P0 is non-negotiable" holds *inside*
265
+ > the gate's tiers rather than across every tier. A stand-down is not silent: the gate records one
266
+ > `stand-down` event per session/preset transition and the browser shows a sticky **GATE OFF**
267
+ > strip above the input. Set `gatePresets: ['*']` to make P0 global again.
233
268
 
234
269
  **Two variants ship**, because a preset's `sandbox` and `approval` are independent knobs and
235
270
  coupling them forced a bad trade:
@@ -255,9 +290,10 @@ default row and the composer picker) and only supplies its own localized labels
255
290
  built-in values, so `cordis.patch.yml` ships the Chinese label for every session.
256
291
 
257
292
  The **icon** is a different story. The composer's glyph map is closed, and its own comment states
258
- the rule: *host-configured names outside the design set get none.* `permissive` is a built-in
259
- value, so 自动审查 already has a shield+eye glyph; `permissive-full` gets the same glyph only
260
- because `npx dsh-perm-gate-patch-glyph` adds it to that map. That patch edits a **host**
293
+ the rule: *host-configured names outside the design set get none.* Neither tier is a built-in value,
294
+ so both get an icon only because `npx dsh-perm-gate-patch-glyph` adds them to that map — 自动审查
295
+ copies the `workspace-write` glyph, 自动审查(高权限) the `danger-full-access` one, so each tier
296
+ shows the glyph of the file sandbox it actually shares. That patch edits a **host**
261
297
  package, so it is lost on every DSH upgrade — see
262
298
  [After a DSH upgrade](./INSTALL.md#after-a-dsh-upgrade-re-apply-the-composer-glyph-patch).
263
299
 
@@ -326,7 +362,13 @@ optionally overridden with `classifierProvider` / `classifierModel`). A **health
326
362
 
327
363
  - `safe` → the call is auto-allowed (audited as the `classifier` source); no panel is shown.
328
364
  - `risky` + a **hard category** (`deletion`, `credential`, `remote`, `system`, `bulk`) → the call
329
- is **auto-denied** without a panel; hard risks are never auto-allowed and never learned.
365
+ **keeps the human ask**. The classifier **never denies**: the deny path belongs to the
366
+ deterministic layers alone (P0 hard-deny, the deny-keyword blacklist, explicit `deny:` rules), so
367
+ a misgraded category is always negotiable instead of an unappealable block. Hard categories stay
368
+ distinct from `neutral` in one way that matters: they are **never learned**, so repeated
369
+ approvals can never sediment them into an auto-allow.
370
+ (Denying on the model's word was measured live: a benign `git commit -F …` graded `remote`
371
+ produced an auto-deny with no panel and no grant to retry with.)
330
372
  - `risky:neutral` → with `riskLearning` enabled (Settings card, off by default), each human
331
373
  approval that actually executes (settled via the host's `tools/result` event) counts toward
332
374
  a `tool|category` key; once the count reaches `riskThreshold` (default 3) **and** the new
@@ -359,9 +401,10 @@ channel existed), with `tools/result` settling the same ask as a fallback when t
359
401
  correlate it. Approvals report the post-approval learning progress (`n`/threshold), and the notice
360
402
  strip labels all three terminal states.
361
403
 
362
- 自动审查 and 自动审查(高权限) both draw the shield+eye glyph in the picker — the first from DSH's
363
- built-in map, the second from the host patch the installation guide describes. Without that patch
364
- the second tier is text-only on every surface; its label and its gating are unaffected.
404
+ 自动审查 and 自动审查(高权限) both draw a glyph in the picker — copied from the built-in tiers whose
405
+ file sandbox they share (`workspace-write` and `danger-full-access`) by the host patch the
406
+ installation guide describes. Without that patch both tiers are text-only on every surface; their
407
+ labels and their gating are unaffected.
365
408
 
366
409
  ### A selectable session tier
367
410
 
@@ -393,6 +436,21 @@ The tier is also adjustable at runtime from **Settings → Plugins → 自动审
393
436
  namespace live, so a change applies to the next tool call without a restart. This is an
394
437
  independent approval class, NOT a generic "auto-approval" mode.
395
438
 
439
+ ### Rule test (dry-run)
440
+
441
+ The same page carries a **Rule test** panel: type a tool name and a command, press Test, and the
442
+ gate judges that call against the ruleset it currently has loaded — without running anything and
443
+ without writing any rule. You get the verdict, the matched rule (index and action), the dimensions
444
+ that rule constrains, and the reason.
445
+
446
+ It reports the effective verdict (the whole P0 → P1 → P2 → P3 → P4 chain) *and* the rule layer's own
447
+ answer, which are not the same thing: a P0 hard-deny or a preset deny-keyword fires before the rule
448
+ chain and leaves no rule index behind, so the panel says "no rule matched" rather than naming an
449
+ unrelated rule. A `0 rules loaded` note means the `rulesFile` path resolved to nothing.
450
+
451
+ The panel talks to `POST /api/dsh-perm-gate/dry-run`, which is **read-only by construction** — it
452
+ has no write form at all, so testing a rule can never change it.
453
+
396
454
  ## CLI
397
455
 
398
456
  Dry-run one call against a rules file (no harness needed):
package/README.zh.md CHANGED
@@ -25,7 +25,8 @@
25
25
  > | DSH 版本 | 分支 | 版本号 | npm 标签 |
26
26
  > | --- | --- | --- | --- |
27
27
  > | 0.1.0-rc.7 ~ 0.1.1-rc.x | `legacy` | `1.x` | `@legacy` |
28
- > | 0.1.2-alpha.1+(含 0.1.5-rc.2) | `main` | `2.x` | `@latest` / `@dsh-0.1.2` (`@2.x` 是范围) |
28
+ > | 0.1.2-alpha.1 ~ 0.1.5-rc.0 | `main` | `2.x` | `@latest` / `@dsh-0.1.2` (`@2.x` 是范围) |
29
+ > | 0.1.5-rc.1+ | `sync/0.1.5-from-main`(=`compat/0.1.5`) | `3.x` | GitHub ref 安装(暂无 dist-tag) |
29
30
  >
30
31
  > 版本序列号跟的是 **DSH 线**(`1.x` = DSH ≤ 0.1.1,`2.x` = DSH 0.1.2+),两条大版本互
31
32
  > 相隔离:锁在 `^1.x` 的安装绝不会解析到 `2.x`,反之亦然。`engines.dsh` 表达同样的
@@ -39,7 +40,7 @@
39
40
  > 线上都是 user-approval 服务的**私有**方法,因此通过 `typeof` 探测读取,缺失
40
41
  > 或抛错时降级为「策略未知」。
41
42
 
42
- 版本 **2.1.2** —— 变更见 [Changelog](./CHANGELOG.md)。
43
+ 版本 **2.6.1** —— 变更见 [Changelog](./CHANGELOG.md)。
43
44
 
44
45
  一个**单一自足、确定性优先、fail-closed** 的 DeepSeek Harness 权限门插件。
45
46
 
@@ -65,6 +66,27 @@
65
66
  - **自动审查档位**(机器值 `permissive`)——一个**独立审批模式**(区别于只读、完全权限与白名单档),既不是"自动审批",也不授予泛化权限。前端只暴露**一个开关**(`permissive`),后台四个审批策略**可组合**、由插件设置决定——仍对 P0 保持 fail-closed。权限下拉框与设置行都按产品名「自动审查」显示;图标见下文(内置档自带,插件档需补丁)。
66
67
  - **沙箱提权自动答复**(`trustEscalation`)— 沙箱提权是从 shell / pwsh / edit 工具**体内部**(`tools/pre-execute` 之后)发出的,所以门禁从未见过它,一个它自动放行的调用仍会弹出确认。开启后,门禁以 `callId` 精确匹配已放行调用并直接答复。
67
68
 
69
+ ## 界面预览
70
+
71
+ **设置卡片 —— 自动审查档位**:前端只暴露一个开关,后台四个策略可组合;`LlmAssist` 可跟随会话当前模型,也可钉住 Provider / Model,并自带健康测试与裁决学习:
72
+
73
+ ![设置卡片 —— 自动审查档位](assets/setting-head.png)
74
+ ![LlmAssist —— Provider / Model / API Key](assets/llms.png)
75
+
76
+ **门禁实际加载的规则**——直接读自 `dsh-perm-gate-rules` 设置命名空间;面板按设计只读,改不了它显示的内容:
77
+
78
+ ![内置规则(只读)](assets/setting-rules.png)
79
+
80
+ **权限审批记录**——本会话每一次裁决,最新在上,附带模型看到的理由:
81
+
82
+ ![权限审批记录](assets/permlist.png)
83
+
84
+ **裁决条**——自动放行会写明依据(只读工具,或 LLM 判定 `safe`);停摆也绝不静默:
85
+
86
+ ![ALLOW 条 —— 只读内置工具](assets/allow1.png)
87
+ ![ALLOW 条 —— llm-assist 判定 safe](assets/allow2.png)
88
+ ![GATE OFF 条 —— 门禁停摆](assets/gateoff.png)
89
+
68
90
  ## 安装
69
91
 
70
92
  需要先安装 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)。
@@ -151,7 +173,14 @@ DSH **自身**的网络流量 —— 内建网络工具与 LLM 传输 —— 刻
151
173
 
152
174
  自动审查是权限下拉框里一个**独立审批档**,与只读 / 工作区内修改 / 完全权限 / 白名单平行。
153
175
  它不是泛化的"自动审批"、也不授予泛化权限:只会在人类/LLM 接缝**之前**收窄或放宽决策,
154
- P0 硬拒绝始终单调且不可协商。
176
+ P0 硬拒绝在**本门禁自身的档位作用域内**始终单调且不可协商。
177
+
178
+ > **P0 是档位作用域内的,不是全局的。** 门禁只在会话权限档位属于 `gatePresets`
179
+ > (默认 `permissive` / `permissive-full`)时生效。其他档位 —— 只读、工作区内修改、完全权限
180
+ > —— 下**整个门禁停用,包括 P0 硬拒绝**,因为该档位自身的策略接管了这个会话。这是刻意设计
181
+ > (见配置表的 `gatePresets`),但也就意味着「P0 不可协商」成立于**门禁的档位之内**,而非所有档位。
182
+ > 停用**不是静默的**:每次会话档位切换会记录一条 `stand-down` 事件,浏览器在输入框上方常驻一条
183
+ > **GATE OFF** 提示条。把 `gatePresets` 设为 `['*']` 可让 P0 重新变成全局。
155
184
 
156
185
  **提供两个变体** —— 因为预设的 `sandbox` 与 `approval` 是两根**独立**旋钮,把它们绑死会逼出
157
186
  一个糟糕的取舍:
@@ -173,9 +202,10 @@ MSYS2/Cygwin 的 `sh.exe`、ConPTY 都会以 `Win32 error 5` / `couldn't create
173
202
  标签,因此 `cordis.patch.yml` 直接写中文名,对所有会话一致。
174
203
 
175
204
  **图标是另一回事。** 输入栏的图标表是闭合的,表自己的注释写明了规则:*host-configured names
176
- outside the design set get none*。`permissive` 是内置值,所以「自动审查」本来就有盾+眼图标;
177
- 「自动审查(高权限)」能拿到同一个图标,靠的是 `npx dsh-perm-gate-patch-glyph` 往那张表里
178
- 加了一项。该补丁改的是**宿主**包,每次 DSH 升级都会丢 —— 见
205
+ outside the design set get none*。两个档位都不是内置值,图标全靠
206
+ `npx dsh-perm-gate-patch-glyph` 往那张表里补两项 ——「自动审查」复制 `workspace-write` 的
207
+ 盾+铅笔,「自动审查(高权限)」复制 `danger-full-access` 的盾+感叹号,各自对应自己实际共用的
208
+ 文件沙箱。该补丁改的是**宿主**包,每次 DSH 升级都会丢 —— 见
179
209
  [DSH 升级后:重打输入区图标补丁](./INSTALL.zh.md)。
180
210
 
181
211
  `cordis.yml`:
@@ -252,7 +282,8 @@ LLM 评定为 `safe` 且门禁自动放行的调用因此仍会弹出确认。
252
282
  开启 `llmAssist` 后,接收 LLM(自定义 OpenAI 兼容端点,或 DSH 宿主模型组——见上文)按结构化协议逐条评估 `ask`。**判定发生在门禁的 `tools/pre-execute` 瀑布内部、决策返回宿主之前**:`safe` 直接放行,审批面板根本不会出现;只有真正无法确定的判定才会弹到你面前。
253
283
 
254
284
  - `safe` → 自动放行(审计来源为 `classifier`),不弹面板。
255
- - `risky` + **硬风险类别**(`deletion`、`credential`、`remote`、`system`、`bulk`)→ **自动拒绝**、不弹面板;硬风险永不自动放行、也永不进入学习。
285
+ - `risky` + **硬风险类别**(`deletion`、`credential`、`remote`、`system`、`bulk`)→ **维持人工确认(ask)**。分类器**永不拒绝**:拒绝只属于确定性层(P0 硬拒绝、黑名单关键词、显式 `deny:` 规则),所以被误判的类别永远可协商,而不会变成无法申诉的封禁。硬类别与 `neutral` 仅保留一条关键区别:**永不进入学习**,因此反复确认也不可能把它沉淀成自动放行。
286
+ (拿模型的判断当拒绝依据是实测出来的问题:一条无害的 `git commit -F …` 被判 `remote`,直接自动拒绝——没有面板、也没有可重试的授权入口。)
256
287
  - `risky:neutral` → 若开启 `riskLearning`(设置卡片内,默认关闭),人工批准且真实执行的 neutral 风险会按 `tool|类别` 计数;计数达到 `riskThreshold`(默认 3)且新调用的操作指纹(命令词 + 目标基名)命中已确认样本时,**同一操作**自动放行。不同目标永不复用该放行。开启学习沉淀(`riskSediment`,默认开)后,满阈值 key 的确认样本会成为**确定性放行规则**:指纹精确命中即直接放行、无需再过 LLM——即使关闭 llmAssist 也继续生效;沉淀规则在设置卡片中可见、可管理(终止学习 / 删除样本)。
257
288
  - 超时(`riskTimeoutMs`,默认 20s,重试 1 次)、传输失败与协议外输出均维持原 `ask`——门禁绝不猜测。
258
289
 
@@ -267,9 +298,9 @@ LLM 评定为 `safe` 且门禁自动放行的调用因此仍会弹出确认。
267
298
  调用文本命中任一关键词(大小写不敏感子串)即直接拒绝,且先于白名单 / 授权 / LLM。黑名单在设置
268
299
  卡片中按列表查看与增删(预置条目带标签,可一键恢复预置);未设置或为空时应用预置列表——黑名单
269
300
  不会静默关闭。
270
- 「自动审查」与「自动审查(高权限)」在选择器里都画盾+眼图标 —— 前者来自 DSH 内置表,后者来自
271
- 安装指南里描述的那次宿主补丁。没有该补丁时,第二个档位在所有界面上都是纯文字;它的标签与门禁
272
- 不受影响。
301
+ 「自动审查」与「自动审查(高权限)」在选择器里都画图标 —— 图标由安装指南里描述的那次宿主补丁
302
+ 从各自共用的文件沙箱档位复制而来(`workspace-write` 与 `danger-full-access`)。没有该补丁时,
303
+ 两个档位在所有界面上都是纯文字;它们的标签与门禁不受影响。
273
304
 
274
305
 
275
306
  ## CLI(独立 dry-run)
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/cordis.patch.yml CHANGED
@@ -17,14 +17,17 @@
17
17
  # The composer's permission picker draws an icon per preset from a CLOSED map
18
18
  # inside @deepseek-ai/dsh-client-ui-conversation (`permissionGlyphs`), whose
19
19
  # source states the rule: "host-configured names outside the design set get
20
- # none". DSH ships a shield+eye glyph for `permissive`, so 自动审查 has an icon,
21
- # but a second plugin tier has none — it falls back to text in the trigger. The
22
- # map is a closure variable with no plugin-facing seam (the option objects the
23
- # host supplies carry only {value, name, description}), so the only way to give
24
- # BOTH tiers the same glyph is to extend that map.
25
- # `scripts/patch-permission-glyph.mjs` does exactly that, duplicating the
26
- # `permissive` entry under `permissive-full`. It patches a HOST package, so it
27
- # must be re-run after any DSH upgrade or reinstall.
20
+ # none". Neither tier is a built-in value, so DSH renders no glyph for either
21
+ # — the map is a closed closure variable and its only plugin-facing input is
22
+ # {value, name, description} on the option objects, which carries no icon. The
23
+ # only way to give a tier an icon is to add an entry to that map.
24
+ # `scripts/patch-permission-glyph.mjs` does exactly that, copying the glyph of
25
+ # the built-in tier whose file sandbox each of ours shares:
26
+ # permissive <- ["workspace-write", (same sandbox: workspace-write)
27
+ # permissive-full <- [FULL_ACCESS, (same sandbox: danger-full-access)
28
+ # The source key is RESOLVED, not assumed (0.1.2 keyed a `permissive` glyph that
29
+ # 0.1.5 dropped, so a literal anchor died with "anchor not found"). It patches a
30
+ # HOST package, so it must be re-run after any DSH upgrade or reinstall.
28
31
  #
29
32
  # The DSH bundle patch REPLACES the whole `permission.config.presets` map (it
30
33
  # does not merge per key), so this file MUST restate every tier that should
@@ -0,0 +1,56 @@
1
+ # Baseline Snapshot: feat/capability-absorption
2
+
3
+ > Captured at T0.3/T0.4 on 2026-09-15
4
+ >
5
+ > **为什么这份快照留在仓库里**:下面那 30 个测试文件是能力吸收(6 维度 / 网络执行面 / 热重载)
6
+ > 落地**之前**的既有覆盖,充当回归护栏——新增行为**不得**靠修改它们来适配。
7
+ > 原位置 `.agents/baseline-snapshot.md` 未入库(`.agents/` 是仓库惯例的本地目录),
8
+ > 2026-09-17 迁到 `docs/` 以便随包分发、可追溯。
9
+ > 注意:仓库当前已有 **40** 个测试文件;本列表是**护栏基线**,不是当前全量清单。
10
+
11
+ ## Gate Status
12
+
13
+ | Gate | Status | Notes |
14
+ |------|--------|-------|
15
+ | typecheck | ✅ 0 errors | After pre-existing fix commit |
16
+ | test | ✅ 278/278 | 30 spec files |
17
+ | build | ✅ tsc + tsdown | client.js 99.95 kB |
18
+ | lint | ✅ 0 errors | — |
19
+
20
+ ## Pre-existing Fixes (committed)
21
+
22
+ - `src/risk.ts:137-138` — cast `req.args` to `Record<string, unknown>` (was `{}`)
23
+ - `src/runtime.ts:26` — add `import { decomposeShellCommand } from './shell.js'`
24
+
25
+ ## Test File List (30 files, DO NOT modify to accommodate new behavior)
26
+
27
+ 1. audit.spec.ts
28
+ 2. auto-allow-tools.spec.ts
29
+ 3. compiler.spec.ts
30
+ 4. custom-llm.spec.ts
31
+ 5. data-home.spec.ts
32
+ 6. deny-keywords.spec.ts
33
+ 7. deny-keywords-scope.spec.ts
34
+ 8. engine.spec.ts
35
+ 9. evaluate.spec.ts
36
+ 10. events.spec.ts
37
+ 11. feature.spec.ts
38
+ 12. grant.spec.ts
39
+ 13. host-llm.spec.ts
40
+ 14. learning.spec.ts
41
+ 15. manual-approval.spec.ts
42
+ 16. patch-presets.spec.ts
43
+ 17. permissive.spec.ts
44
+ 18. pre-execute.spec.ts
45
+ 19. preset-scope.spec.ts
46
+ 20. review.spec.ts
47
+ 21. risk.spec.ts
48
+ 22. rule.spec.ts
49
+ 23. runtime.spec.ts
50
+ 24. runtime-risk.spec.ts
51
+ 25. sediment.spec.ts
52
+ 26. session-resolution.spec.ts
53
+ 27. session-sweep.spec.ts
54
+ 28. session-sweep-apply.spec.ts
55
+ 29. shell.spec.ts
56
+ 30. write-path.spec.ts
@@ -0,0 +1,217 @@
1
+ # Plan: rules.yml → DSH Settings Namespace Migration
2
+
3
+ ## Goal
4
+
5
+ Eliminate the external `rules.yml` file dependency by migrating rules into the DSH settings namespace (`dsh-perm-gate.rules`). This solves the cold-start directory problem at the root cause: settings are managed by the framework and loaded before any plugin `apply()` runs.
6
+
7
+ ## Current Architecture
8
+
9
+ ```
10
+ User edits rules.yml (or allowlist card writes to it)
11
+ ↓
12
+ resolveRulesFile() → path string
13
+ ↓
14
+ readFileSafe() → raw YAML text
15
+ ↓
16
+ parsePermissionsDocument() → structured doc
17
+ ↓
18
+ compileDocument() → CompiledRuleset
19
+ ↓
20
+ PermGateRuntime.decide() → verdict
21
+ ```
22
+
23
+ Read-only view:
24
+ ```
25
+ GET /api/dsh-perm-gate/rules
26
+ → readRulesView(rulesFile)
27
+ → statSync + readFileSync + parse + compile
28
+ → RulesView { path, exists, bytes, hash, raw, counts, error }
29
+ ```
30
+
31
+ Write path (allowlist card):
32
+ ```
33
+ appendAllowCommand() / replaceAllowCommands()
34
+ → readFileSync → parse → mutate → stringify → writeFileSync
35
+ ```
36
+
37
+ ## Target Architecture
38
+
39
+ ```
40
+ User edits via settings card (or API)
41
+ ↓
42
+ settings.get('dsh-perm-gate.rules') → structured object (already parsed)
43
+ ↓
44
+ compileDocument(rules) → CompiledRuleset
45
+ ↓
46
+ PermGateRuntime.decide() → verdict
47
+ ```
48
+
49
+ Read-only view:
50
+ ```
51
+ GET /api/dsh-perm-gate/rules
52
+ → settings.get('dsh-perm-gate.rules')
53
+ → RulesView { source: 'settings', rules: {...}, counts, hash }
54
+ ```
55
+
56
+ Write path (allowlist card):
57
+ ```
58
+ settings.update({ rules: { allow: [...] } })
59
+ → framework persists to settings.yaml
60
+ → settings.watch() triggers reload
61
+ ```
62
+
63
+ ## Schema (config.ts)
64
+
65
+ ```typescript
66
+ // New settings schema for rules
67
+ const RuleEntrySchema = z.object({
68
+ command: z.array(z.string()).optional(),
69
+ args: z.array(z.string()).optional(),
70
+ tools: z.array(z.string()).optional(),
71
+ network: z.object({
72
+ domains: z.array(z.string()),
73
+ }).optional(),
74
+ reason: z.string().default(''),
75
+ })
76
+
77
+ const RulesSchema = z.object({
78
+ defaultAction: z.enum(['allow', 'deny', 'ask']).default('ask'),
79
+ deny: z.array(RuleEntrySchema).default([]),
80
+ allow: z.array(RuleEntrySchema).default([]),
81
+ ask: z.array(RuleEntrySchema).default([]),
82
+ network: z.object({
83
+ domains: z.array(z.string()).default([]),
84
+ }).optional(),
85
+ }).default({ defaultAction: 'ask', deny: [], allow: [] })
86
+ ```
87
+
88
+ ## Migration Steps
89
+
90
+ ### Phase 1: Dual-Source (backward compatible)
91
+
92
+ 1. **Add `rules` to settings schema** — new field in `dsh-perm-gate` namespace
93
+ 2. **Runtime reads from settings first, falls back to file** — `settings.get('rules') ?? loadRulesFile()`
94
+ 3. **Allowlist card writes to settings** — `settings.update({ rules: { allow: [...] } })`
95
+ 4. **Rules view serves from settings** — `readRulesView()` reads from settings object, not file
96
+ 5. **Module-level: still create dataDir** — belt-and-suspenders for the transition period
97
+
98
+ ### Phase 2: File Deprecation
99
+
100
+ 6. **Log deprecation warning** — when `rulesFile` is configured, warn that it's deprecated
101
+ 7. **Import button** — UI can import existing `rules.yml` into settings
102
+ 8. **Export button** — UI can export settings rules as YAML for version control
103
+
104
+ ### Phase 3: File Removal
105
+
106
+ 9. **Remove `rulesFile` config option** — no longer accepted
107
+ 10. **Remove file I/O code** — `allowlist.ts`, `rules-view.ts` file operations
108
+ 11. **Remove chokidar watcher** — settings has built-in `watch()`
109
+ 12. **Remove dataDir bootstrap** — no longer needed
110
+
111
+ ## How the Read-Only View Changes
112
+
113
+ ### Before (file-based)
114
+
115
+ ```json
116
+ {
117
+ "path": "C:\\Users\\joshua\\.dsh\\perm-gate\\rules.yml",
118
+ "exists": true,
119
+ "bytes": 1513,
120
+ "hash": "abc123...",
121
+ "lines": 61,
122
+ "raw": "permissions:\n defaultAction: ask\n deny:\n ...",
123
+ "truncated": false,
124
+ "defaultAction": "ask",
125
+ "counts": { "allow": 11, "deny": 1, "ask": 0 },
126
+ "error": null
127
+ }
128
+ ```
129
+
130
+ ### After (settings-based)
131
+
132
+ ```json
133
+ {
134
+ "source": "settings",
135
+ "defaultAction": "ask",
136
+ "counts": { "allow": 11, "deny": 1, "ask": 0 },
137
+ "hash": "abc123...",
138
+ "rules": {
139
+ "defaultAction": "ask",
140
+ "deny": [
141
+ { "tools": ["shell", "pwsh"], "command": ["rm#recursive"], "reason": "destructive" }
142
+ ],
143
+ "allow": [
144
+ { "command": ["grep"], "reason": "permissive allowlist" }
145
+ ],
146
+ "network": {
147
+ "domains": ["github.com", "registry.npmjs.org"]
148
+ }
149
+ }
150
+ }
151
+ ```
152
+
153
+ **Key differences:**
154
+ - `path` / `exists` / `bytes` / `raw` / `truncated` → removed (no file)
155
+ - `source: 'settings'` → added (provenance)
156
+ - `rules` → the structured object (already parsed, not raw YAML)
157
+ - `counts` / `defaultAction` / `hash` → kept (useful for UI)
158
+
159
+ ## How the Allowlist Write Path Changes
160
+
161
+ ### Before (file-based)
162
+
163
+ ```typescript
164
+ // appendAllowCommand reads YAML, mutates, writes back
165
+ appendAllowCommand(rulesFile, 'git push', 'permissive allow-everywhere')
166
+ // → readFileSync → parse → push → stringify → writeFileSync
167
+ ```
168
+
169
+ ### After (settings-based)
170
+
171
+ ```typescript
172
+ // Direct settings update
173
+ const current = settings.get('dsh-perm-gate.rules')
174
+ const allow = [...(current.allow ?? []), { command: ['git push'], reason: 'permissive allow-everywhere' }]
175
+ settings.update({ rules: { ...current, allow } })
176
+ // → framework persists → settings.watch() triggers reload
177
+ ```
178
+
179
+ ## Files Changed
180
+
181
+ | File | Change |
182
+ |---|---|
183
+ | `src/config.ts` | Add `RulesSchema`, keep `resolveRulesFile` for backward compat |
184
+ | `src/index.ts` | Read from settings first, fall back to file; register settings schema |
185
+ | `src/runtime.ts` | Accept `rules` object directly (no file read) |
186
+ | `src/rules-view.ts` | Add `readRulesViewFromSettings()` |
187
+ | `src/allowlist.ts` | Add `appendAllowToSettings()`, `replaceAllowInSettings()` |
188
+ | `src/events.ts` | Update `registerRulesRoute` to use settings view |
189
+ | `src/watch.ts` | Remove chokidar watcher (settings has built-in watch) |
190
+
191
+ ## Cold-Start Resilience (All Plugins)
192
+
193
+ The same pattern should be applied to other plugins that read files at startup:
194
+
195
+ | Plugin | Data | Risk | Fix |
196
+ |---|---|---|---|
197
+ | `dsh-perm-gate` | rules.yml | HIGH | Settings migration (this plan) |
198
+ | `dsh-search-index` | index files | MEDIUM | dataDir bootstrap + try/catch |
199
+ | `dsh-session-guard` | guard config | LOW | Already settings-backed |
200
+ | `dsh-prime-memory` | memory files | MEDIUM | dataDir bootstrap + try/catch |
201
+ | `dsh-context-compression` | config | LOW | Already settings-backed |
202
+
203
+ **Universal pattern:**
204
+ 1. Module-level `ensureDataDir()` — belt
205
+ 2. Read paths: `dataDirReady()` check → empty defaults if missing
206
+ 3. Write paths: `ensureDataDir()` before first write
207
+ 4. All file reads wrapped in try/catch with graceful fallback
208
+
209
+ ## Testing
210
+
211
+ - [ ] Unit: `compileDocument` with settings-sourced rules (same as YAML-sourced)
212
+ - [ ] Unit: `readRulesViewFromSettings` returns correct `RulesView`
213
+ - [ ] Unit: `appendAllowToSettings` correctly mutates settings object
214
+ - [ ] Integration: settings card reads and displays rules
215
+ - [ ] Integration: allowlist card writes persist to settings
216
+ - [ ] Migration: existing `rules.yml` imports into settings correctly
217
+ - [ ] Cold start: plugin loads with empty settings (no rules configured)