dsh-perm-gate 2.1.0 → 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 (84) hide show
  1. package/CHANGELOG.ja.md +108 -0
  2. package/CHANGELOG.ko.md +104 -0
  3. package/CHANGELOG.md +347 -0
  4. package/INSTALL.ja.md +84 -8
  5. package/INSTALL.ko.md +80 -9
  6. package/INSTALL.md +83 -8
  7. package/INSTALL.zh.md +70 -7
  8. package/README.ja.md +130 -16
  9. package/README.ko.md +128 -17
  10. package/README.md +179 -25
  11. package/README.zh.md +121 -12
  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 +37 -3
  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 +22 -0
  26. package/examples/permissions.example.yaml +16 -1
  27. package/lib/agent-identity.d.ts +43 -0
  28. package/lib/agent-identity.js +95 -0
  29. package/lib/allowlist.d.ts +27 -0
  30. package/lib/allowlist.js +103 -1
  31. package/lib/cli.js +11 -14
  32. package/lib/client.js +598 -6
  33. package/lib/client.js.map +1 -1
  34. package/lib/command-dispatcher.d.ts +22 -0
  35. package/lib/command-dispatcher.js +97 -0
  36. package/lib/command-semantics.d.ts +62 -0
  37. package/lib/command-semantics.js +6 -0
  38. package/lib/compiler.d.ts +50 -0
  39. package/lib/compiler.js +122 -0
  40. package/lib/config.d.ts +161 -7
  41. package/lib/config.js +78 -11
  42. package/lib/dry-run.d.ts +87 -0
  43. package/lib/dry-run.js +110 -0
  44. package/lib/engine.js +61 -4
  45. package/lib/evaluate.d.ts +9 -0
  46. package/lib/evaluate.js +264 -1
  47. package/lib/events.d.ts +69 -2
  48. package/lib/events.js +123 -0
  49. package/lib/index.d.ts +1 -1
  50. package/lib/index.js +320 -11
  51. package/lib/network-lifecycle.d.ts +107 -0
  52. package/lib/network-lifecycle.js +180 -0
  53. package/lib/network.d.ts +109 -0
  54. package/lib/network.js +196 -0
  55. package/lib/parsers/git.d.ts +43 -0
  56. package/lib/parsers/git.js +404 -0
  57. package/lib/parsers/shell-cmds.d.ts +32 -0
  58. package/lib/parsers/shell-cmds.js +293 -0
  59. package/lib/preset.d.ts +13 -0
  60. package/lib/preset.js +17 -0
  61. package/lib/proxy.d.ts +160 -0
  62. package/lib/proxy.js +529 -0
  63. package/lib/risk.d.ts +17 -3
  64. package/lib/risk.js +19 -6
  65. package/lib/rule-chain.d.ts +39 -0
  66. package/lib/rule-chain.js +213 -0
  67. package/lib/rule-dims.d.ts +174 -0
  68. package/lib/rule-dims.js +230 -0
  69. package/lib/rule.d.ts +60 -0
  70. package/lib/rule.js +69 -7
  71. package/lib/rules-view.d.ts +52 -0
  72. package/lib/rules-view.js +126 -0
  73. package/lib/runtime.d.ts +208 -7
  74. package/lib/runtime.js +433 -27
  75. package/lib/session-sweep.d.ts +45 -0
  76. package/lib/session-sweep.js +153 -0
  77. package/lib/shadow.d.ts +34 -0
  78. package/lib/shadow.js +143 -0
  79. package/lib/shell.d.ts +14 -0
  80. package/lib/shell.js +17 -0
  81. package/lib/watch.d.ts +42 -0
  82. package/lib/watch.js +170 -0
  83. package/package.json +26 -3
  84. package/scripts/patch-permission-glyph.mjs +392 -0
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.0.0** — 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
 
@@ -77,11 +78,13 @@ cross-plugin version coupling.
77
78
  malformed rules, and source-hash compile caching.
78
79
  - **Audit** — every decision is logged as an `{ignorable:true}` event with its `callId`; the
79
80
  model-visible reason matches the recorded outcome.
80
- - **自动审查 tier** (`permissive`) — an **independent approval mode** (separate from read-only,
81
- full-access and whitelist tiers) that is neither "auto-approve" nor blanket trust. Front-end
82
- exposes a **single switch** (`permissive`); the four backend strategies are **combinable** and
83
- driven by plugin settings — still fail-closed against P0. The permission picker and the
84
- settings row both show it under the product label 自动审查, with no icon.
81
+ - **自动审查 tier** (`permissive`, plus `permissive-full`) — an **independent approval mode**
82
+ (separate from read-only, full-access and whitelist tiers) that is neither "auto-approve" nor
83
+ blanket trust. Front-end exposes a **single switch** (`permissive`); the four backend strategies
84
+ are **combinable** and driven by plugin settings — still fail-closed against P0. The permission
85
+ picker and the settings row both show it under the product label 自动审查, with no icon. The
86
+ `permissive-full` variant keeps the identical approval behaviour but drops the built-in file
87
+ sandbox, which otherwise denies the named pipes `git clone` / Cygwin / ConPTY need.
85
88
  - **Sandbox-escalation auto-answer** (`trustEscalation`) — a sandbox escalation is asked from
86
89
  *inside* the shell / pwsh / edit tool body, after `tools/pre-execute`, so the gate never saw
87
90
  it and a call it auto-allowed still prompted you to approve the widening. With this strategy
@@ -102,6 +105,31 @@ grants and LLM. It is editable as a list in the settings card (preset entries ar
102
105
  one-click restore brings the preset back); unset or empty applies the preset — the blacklist
103
106
  never silently turns off.
104
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
+
105
133
  ## Install
106
134
 
107
135
  Requires an existing [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) installation.
@@ -125,9 +153,24 @@ Add the plugin to `cordis.yml`:
125
153
  rulesFile: ./permissions.yaml # optional; defaults to $DSH_HOME/perm-gate/rules.yml
126
154
  dshHome: $DSH_HOME # root pinned for protected-target checks
127
155
  defaultAction: ask # allow | ask | deny
128
- gatePresets: [permissive] # tiers where the gate is active at all (default)
156
+ gatePresets: [permissive, permissive-full] # tiers where the gate is active (default)
157
+ sessionSweep: true # hourly cleanup of archived/dead sessions' gate data
129
158
  ```
130
159
 
160
+ ### Session sweep
161
+
162
+ On startup and every hour the gate reads DSH's workspace store
163
+ (`$DSH_HOME/storages/workspace.json`, read-only) and classifies every session it
164
+ holds authorization-chain data for. Sessions DSH has archived (`global.archivedSessionIds`)
165
+ or no longer tracks at all have their decision events dropped from
166
+ `$DSH_HOME/perm-gate/events.jsonl` and their pre-change snapshot files deleted
167
+ from `$DSH_HOME/perm-gate/snapshots/` — history the review page can no longer
168
+ reach, for data the harness itself considers gone. Live sessions are untouched,
169
+ unattributable rows (empty session id) are never deleted, and any failure is
170
+ fail-open: the round is skipped and retried an hour later. Set `sessionSweep: false`
171
+ to disable; `workspaceStoreFile` overrides the store path. Restoring an archived
172
+ session does not restore its swept history.
173
+
131
174
  ### Rules file
132
175
 
133
176
  ```yaml
@@ -154,18 +197,105 @@ permissions:
154
197
  A command entry `word#flag` matches the command word (`word`) with the modifier `recursive` or
155
198
  `force` — so `rm#recursive` matches `rm -rf`, `env rm -rf`, and `sh -c "rm -rf /"`.
156
199
 
200
+ ### Network policy (opt-in)
201
+
202
+ A local HTTP/CONNECT proxy that adjudicates the outbound traffic of **shell
203
+ subprocesses** against the same rules file, plus an approval path for targets no
204
+ rule covers. **Off by default** — enabling it binds a loopback port and rewrites
205
+ the proxy environment for child processes, so it is never turned on implicitly.
206
+
207
+ ```yaml
208
+ - id: dsh-perm-gate
209
+ config:
210
+ networkEnabled: false # master switch (default false)
211
+ networkMode: whitelist # deny-all | whitelist | allow-all
212
+ networkUnlisted: ask # ask | deny — unlisted target handling
213
+ networkUnattributed: allow # allow | deny — traffic with no shell attribution
214
+ networkInjectEnv: true # rewrite HTTP(S)_PROXY/ALL_PROXY for children
215
+ networkAskTimeoutMs: 120000 # approval wait before failing closed
216
+ networkGrantTtlMs: 1800000 # how long one approval covers its target
217
+ ```
218
+
219
+ **Tiered behaviour.** Nothing reaches the network without an allow rule.
220
+ An unlisted target is escalated to the interactive approval seam, raised on
221
+ behalf of the shell command that opened the connection; approving widens reach
222
+ for that target for the session. A `deny` rule is **never** escalated —
223
+ approval can widen what an unlisted target may reach, but it can never override
224
+ a rule that says no.
225
+
226
+ **The boundary — read this before relying on it.** The proxy is a *cooperative*
227
+ policy layer, not an enforcement boundary. It only sees traffic from clients
228
+ that read the proxy environment:
229
+
230
+ | Client | Covered? |
231
+ |--------|----------|
232
+ | `curl`, `wget`, `git`, Go `net/http`, Python `requests` | yes |
233
+ | **Node.js `http`/`https`/`fetch`** | **no — connects directly** |
234
+ | Java (without `-D` proxy flags), .NET `HttpClient` | no |
235
+ | Raw sockets, custom TCP | no |
236
+ | DNS, QUIC/HTTP3, non-HTTP protocols | no |
237
+ | Connections to a literal IP | no |
238
+
239
+ So a shell command like `node -e "require('http').get('http://host/')"` is not
240
+ intercepted. Treat this as a guardrail against accidents and a place to state
241
+ intent, not as a hermetic sandbox.
242
+
243
+ DSH's **own** network traffic — the built-in network tools and the LLM
244
+ transport — is deliberately left alone. Those connections carry no shell
245
+ attribution, and `networkUnattributed: allow` (the default) passes them
246
+ through unreviewed: reviewing them would let the host block *itself*, which is
247
+ a worse failure than a missed block. Set `networkUnattributed: deny` only if
248
+ you know your host's clients ignore the proxy environment.
249
+
250
+ Query the live state at `GET /api/dsh-perm-gate/network` (mode, bind, port,
251
+ proxy liveness, env-injection state, block counters, recent blocks).
252
+
157
253
  ## The 自动审查 tier (machine value `permissive`)
158
254
 
159
255
  自动审查 is an **independent approval tier** in the DSH permission picker, parallel to
160
256
  Read Only / Workspace Write / Full access / Whitelist. It is **not** generic "auto-approval" and
161
257
  never mints blanket authority: it only narrows or widens the seam *before* the human/LLM step
162
- 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.
268
+
269
+ **Two variants ship**, because a preset's `sandbox` and `approval` are independent knobs and
270
+ coupling them forced a bad trade:
271
+
272
+ | Picker label | Machine value | sandbox | approval |
273
+ |--------------|---------------|---------|----------|
274
+ | 自动审查 | `permissive` | `workspace-write` | `ask` |
275
+ | 自动审查(高权限) | `permissive-full` | `danger-full-access` | `ask` |
276
+
277
+ The plain tier keeps the built-in file sandbox. That sandbox also denies the named pipes a child
278
+ process needs to start, so `git clone`, MSYS2/Cygwin `sh.exe` and ConPTY fail under it with
279
+ `Win32 error 5` / `couldn't create signal pipe`. Because the gate is active **only** in the tiers
280
+ listed in `gatePresets`, wanting the gate meant accepting that restriction. 自动审查(高权限)
281
+ removes the coupling: identical approval behaviour, no file-sandbox restriction. The tier's own
282
+ description states the trade plainly — the workflow is smoother, approvals still apply per call,
283
+ but there is **no system sandbox left as a backstop**. Both are in the
284
+ default `gatePresets`, so either one gives you the full P0–P4 chain — the gate reads the preset
285
+ **name** only, never the sandbox mode.
163
286
 
164
287
  The picker label is a **host-supplied product string**, not a per-locale dictionary entry: DSH
165
- 0.1.2 renders a plugin tier's `name:` verbatim on both permission surfaces (the General-settings
288
+ renders a plugin tier's `name:` verbatim on both permission surfaces (the General-settings
166
289
  default row and the composer picker) and only supplies its own localized labels for the three
167
- built-in values, so `cordis.patch.yml` ships the Chinese label for every session. The tier draws
168
- **no icon** — the composer renders glyphs only for the built-in values.
290
+ built-in values, so `cordis.patch.yml` ships the Chinese label for every session.
291
+
292
+ The **icon** is a different story. The composer's glyph map is closed, and its own comment states
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**
297
+ package, so it is lost on every DSH upgrade — see
298
+ [After a DSH upgrade](./INSTALL.md#after-a-dsh-upgrade-re-apply-the-composer-glyph-patch).
169
299
 
170
300
  In `cordis.yml`:
171
301
 
@@ -232,7 +362,13 @@ optionally overridden with `classifierProvider` / `classifierModel`). A **health
232
362
 
233
363
  - `safe` → the call is auto-allowed (audited as the `classifier` source); no panel is shown.
234
364
  - `risky` + a **hard category** (`deletion`, `credential`, `remote`, `system`, `bulk`) → the call
235
- 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.)
236
372
  - `risky:neutral` → with `riskLearning` enabled (Settings card, off by default), each human
237
373
  approval that actually executes (settled via the host's `tools/result` event) counts toward
238
374
  a `tool|category` key; once the count reaches `riskThreshold` (default 3) **and** the new
@@ -265,8 +401,10 @@ channel existed), with `tools/result` settling the same ask as a fallback when t
265
401
  correlate it. Approvals report the post-approval learning progress (`n`/threshold), and the notice
266
402
  strip labels all three terminal states.
267
403
 
268
- The tier draws no icon in the permission picker: the composer's glyphs are keyed to the three
269
- built-in values, so a plugin-contributed tier is text-only on every surface.
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.
270
408
 
271
409
  ### A selectable session tier
272
410
 
@@ -278,16 +416,17 @@ built-ins (`read-only` / `workspace-write` / `danger-full-access`, from
278
416
  session permission picker offers 自动审查 as an independent selectable approval tier, not a
279
417
  generic "auto-approval" mode.
280
418
 
281
- The gate is active **only in the tiers listed in `gatePresets`** (default `['permissive']`, the
282
- tier this plugin adds). In every other tier — Read Only, Workspace Write, Full access,
283
- `custom` — the gate's decision flow does not run at all: no allow, no ask, no deny, no P0
284
- hard-deny, no deny-keyword veto, and no audit event. The selected tier's own policy governs the
285
- call, which is the point: `danger-full-access` is defined as "full access without approval
286
- prompts", so overruling it with an ask (unanswerable there — the approval seam rejects before any
287
- answerer runs, producing `the user rejected tool "..."` with no panel) or with a hard-deny would
288
- silently contradict the tier the user chose. `gatePresets: ['*']` makes the gate global again
289
- (hard-deny included); inside an active tier an `ask` is still degraded to passthrough when the
290
- session's effective approval policy is `never`.
419
+ The gate is active **only in the tiers listed in `gatePresets`** (default
420
+ `['permissive', 'permissive-full']`, the two tiers this plugin adds). In every other tier —
421
+ Read Only, Workspace Write, Full access, `custom` — the gate's decision flow does not run at all:
422
+ no allow, no ask, no deny, no P0 hard-deny, no deny-keyword veto, and no audit event. The
423
+ selected tier's own policy governs the call, which is the point: the built-in
424
+ `danger-full-access` is defined as "full access without approval prompts", so overruling it with
425
+ an ask (unanswerable there — the approval seam rejects before any answerer runs, producing
426
+ `the user rejected tool "..."` with no panel) or with a hard-deny would silently contradict the
427
+ tier the user chose. `gatePresets: ['*']` makes the gate global again (hard-deny included);
428
+ inside an active tier an `ask` is still degraded to passthrough when the session's effective
429
+ approval policy is `never` — which is why both 自动审查 tiers declare `approval: ask`.
291
430
 
292
431
  ### Configurable in the UI
293
432
 
@@ -297,6 +436,21 @@ The tier is also adjustable at runtime from **Settings → Plugins → 自动审
297
436
  namespace live, so a change applies to the next tool call without a restart. This is an
298
437
  independent approval class, NOT a generic "auto-approval" mode.
299
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
+
300
454
  ## CLI
301
455
 
302
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.0.0** —— 变更见 [Changelog](./CHANGELOG.md)。
43
+ 版本 **2.6.1** —— 变更见 [Changelog](./CHANGELOG.md)。
43
44
 
44
45
  一个**单一自足、确定性优先、fail-closed** 的 DeepSeek Harness 权限门插件。
45
46
 
@@ -62,9 +63,30 @@
62
63
  - **会话放行** — 精确的 `(工具, 规范化 fingerprint)` grant,带 `TTL` + `maxUses`;换目标绝不复用。子代理继承但不可自授。
63
64
  - **纯函数规则引擎** — glob/regex 编译 + ReDoS 上限、坏规则 loud fail、按源内容哈希缓存。
64
65
  - **审计** — 每次决策写为 `{ignorable:true}` 事件并带 `callId`;模型可见理由与记录一致。
65
- - **自动审查档位**(机器值 `permissive`)——一个**独立审批模式**(区别于只读、完全权限与白名单档),既不是"自动审批",也不授予泛化权限。前端只暴露**一个开关**(`permissive`),后台四个审批策略**可组合**、由插件设置决定——仍对 P0 保持 fail-closed。权限下拉框与设置行都按产品名「自动审查」显示,且不带图标。
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)。
@@ -87,21 +109,104 @@ dsh plugin --profile web add dsh-perm-gate
87
109
  rulesFile: ./permissions.yaml # 可选;默认 $DSH_HOME/perm-gate/rules.yml
88
110
  dshHome: $DSH_HOME
89
111
  defaultAction: ask
90
- gatePresets: [permissive] # 门禁生效的档位(默认值)
112
+ gatePresets: [permissive, permissive-full] # 门禁生效的档位(默认值)
113
+ sessionSweep: true # 每小时清理已归档/已删除会话的门禁数据
91
114
  ```
92
115
 
116
+ ### 会话清扫(session sweep)
117
+
118
+ 插件启动时及每小时读取 DSH 的工作区存储(`$DSH_HOME/storages/workspace.json`,只读),
119
+ 对门禁持有授权链数据的每个会话做归类。已被 DSH 归档(`global.archivedSessionIds`)
120
+ 或彻底不存在的会话,其决策事件会从 `$DSH_HOME/perm-gate/events.jsonl` 中移除,
121
+ 其决策前文件快照会从 `$DSH_HOME/perm-gate/snapshots/` 中删除——宿主已视为消失的数据,
122
+ 审查页也不再保留其历史。活跃会话不受影响;无法归属的行(空 sessionId)永不删除;
123
+ 任何失败都 fail-open:本轮跳过,一小时后重试。设 `sessionSweep: false` 关闭;
124
+ `workspaceStoreFile` 可覆盖存储路径。恢复归档会话不会找回已被清扫的历史。
125
+
93
126
  规则示例:见 [examples/permissions.example.yaml](./examples/permissions.example.yaml)。
94
127
 
128
+ ### 网络策略(可选开启)
129
+
130
+ 本地 HTTP/CONNECT 代理,用**同一份规则文件**审查 **shell 子进程**的出站流量,并对无规则
131
+ 覆盖的目标提供审批通道。**默认关闭** —— 开启后会绑定回环端口并改写子进程的代理环境变量,
132
+ 因此绝不隐式启用。
133
+
134
+ ```yaml
135
+ - id: dsh-perm-gate
136
+ config:
137
+ networkEnabled: false # 总开关(默认 false)
138
+ networkMode: whitelist # deny-all | whitelist | allow-all
139
+ networkUnlisted: ask # ask | deny —— 未列出目标的处理方式
140
+ networkUnattributed: allow # allow | deny —— 无 shell 归属的流量
141
+ networkInjectEnv: true # 为子进程改写 HTTP(S)_PROXY / ALL_PROXY
142
+ networkAskTimeoutMs: 120000 # 审批等待上限,超时按拒绝处理
143
+ networkGrantTtlMs: 1800000 # 一次批准的会话有效期
144
+ ```
145
+
146
+ **分层行为**:没有 allow 规则,任何目标都出不去。未列出的目标会升级到交互审批,挂在该
147
+ shell 命令的会话上;批准后该目标在本次会话内放行。**`deny` 规则永不升级为审批** —— 审批
148
+ 只能为「无规则禁止的目标」拓宽可达性,永远不能推翻一条说「不」的规则。
149
+
150
+ **边界 —— 依赖它之前请先读这段**:代理是**协作式**策略层,不是强制边界。它只能看到
151
+ **愿意读代理环境变量**的客户端的流量。
152
+
153
+ | 客户端 | 能拦吗 |
154
+ |--------|--------|
155
+ | `curl`、`wget`、`git`、Go `net/http`、Python `requests` | ✅ |
156
+ | **Node.js `http` / `https` / `fetch`** | ❌ **直连,代理看不到** |
157
+ | Java(未加 `-D` 代理参数)、.NET `HttpClient` | ❌ |
158
+ | 原始 socket、自写 TCP | ❌ |
159
+ | DNS、QUIC/HTTP3、非 HTTP 协议 | ❌ |
160
+ | 连接字面 IP | ❌ |
161
+
162
+ 因此 `node -e "require('http').get('http://host/')"` 这类命令**不会被拦截**。请把它当作
163
+ 「防误操作的护栏 + 声明意图的地方」,而不是密闭沙箱。
164
+
165
+ DSH **自身**的网络流量 —— 内建网络工具与 LLM 传输 —— 刻意不管:这些连接不带 shell 归属,
166
+ 而 `networkUnattributed: allow`(默认)会直接放行。审查它们会导致宿主**把自己拦死**,
167
+ 那比漏拦严重得多。只有在你确定宿主的客户端不读代理环境变量时,才考虑改成 `deny`。
168
+
169
+ 实时状态查询:`GET /api/dsh-perm-gate/network`(模式 / 绑定 / 端口 / 代理存活 / 环境注入
170
+ 状态 / 阻断计数 / 最近阻断)。
171
+
95
172
  ## 自动审查档位(机器值 `permissive`)
96
173
 
97
174
  自动审查是权限下拉框里一个**独立审批档**,与只读 / 工作区内修改 / 完全权限 / 白名单平行。
98
175
  它不是泛化的"自动审批"、也不授予泛化权限:只会在人类/LLM 接缝**之前**收窄或放宽决策,
99
- P0 硬拒绝始终单调且不可协商。
100
-
101
- 下拉框里的名字是**宿主提供的产品名**,不是逐语言的字典项:DSH 0.1.2 对插件档位在**两个**权限界面上
176
+ P0 硬拒绝在**本门禁自身的档位作用域内**始终单调且不可协商。
177
+
178
+ > **P0 是档位作用域内的,不是全局的。** 门禁只在会话权限档位属于 `gatePresets`
179
+ > (默认 `permissive` / `permissive-full`)时生效。其他档位 —— 只读、工作区内修改、完全权限
180
+ > —— 下**整个门禁停用,包括 P0 硬拒绝**,因为该档位自身的策略接管了这个会话。这是刻意设计
181
+ > (见配置表的 `gatePresets`),但也就意味着「P0 不可协商」成立于**门禁的档位之内**,而非所有档位。
182
+ > 停用**不是静默的**:每次会话档位切换会记录一条 `stand-down` 事件,浏览器在输入框上方常驻一条
183
+ > **GATE OFF** 提示条。把 `gatePresets` 设为 `['*']` 可让 P0 重新变成全局。
184
+
185
+ **提供两个变体** —— 因为预设的 `sandbox` 与 `approval` 是两根**独立**旋钮,把它们绑死会逼出
186
+ 一个糟糕的取舍:
187
+
188
+ | 下拉框名称 | 机器值 | sandbox | approval |
189
+ |-----------|--------|---------|----------|
190
+ | 自动审查 | `permissive` | `workspace-write` | `ask` |
191
+ | 自动审查(高权限) | `permissive-full` | `danger-full-access` | `ask` |
192
+
193
+ 普通档保留内置文件沙箱。而那个沙箱**同时**拒绝子进程启动所需的命名管道 —— 所以 `git clone`、
194
+ MSYS2/Cygwin 的 `sh.exe`、ConPTY 都会以 `Win32 error 5` / `couldn't create signal pipe` 失败。
195
+ 又因为门禁**只在 `gatePresets` 列出的档位里生效**,想用门禁就必须接受这个限制。
196
+ 「自动审查(高权限)」解开了这个耦合:**审批行为完全相同,但不限制文件沙箱** —— 档位自带的描述已把代价
197
+ 写明:流程更顺畅、审批仍逐次生效,但**不再有系统沙箱兜底**。两者都在默认
198
+ `gatePresets` 里,任选其一都能获得完整的 P0–P4 链路 —— 门禁只读预设的**名字**,从不读 sandbox 模式。
199
+
200
+ 下拉框里的名字是**宿主提供的产品名**,不是逐语言的字典项:DSH 对插件档位在**两个**权限界面上
102
201
  (通用设置默认档行、输入栏权限选择器)都原样渲染补丁里的 `name:`,只给三个内置档提供自己的本地化
103
- 标签,因此 `cordis.patch.yml` 直接写中文名,对所有会话一致。该档位**不画图标**——选择器的图标只按
104
- 三个内置值取。
202
+ 标签,因此 `cordis.patch.yml` 直接写中文名,对所有会话一致。
203
+
204
+ **图标是另一回事。** 输入栏的图标表是闭合的,表自己的注释写明了规则:*host-configured names
205
+ outside the design set get none*。两个档位都不是内置值,图标全靠
206
+ `npx dsh-perm-gate-patch-glyph` 往那张表里补两项 ——「自动审查」复制 `workspace-write` 的
207
+ 盾+铅笔,「自动审查(高权限)」复制 `danger-full-access` 的盾+感叹号,各自对应自己实际共用的
208
+ 文件沙箱。该补丁改的是**宿主**包,每次 DSH 升级都会丢 —— 见
209
+ [DSH 升级后:重打输入区图标补丁](./INSTALL.zh.md)。
105
210
 
106
211
  `cordis.yml`:
107
212
 
@@ -156,7 +261,8 @@ LLM 评定为 `safe` 且门禁自动放行的调用因此仍会弹出确认。
156
261
  `@deepseek-ai/dsh-base/cordis.patch.yml`);`test/patch-presets.spec.ts` 固定了这份键集合。因此会话权限
157
262
  下拉里会出现「自动审查」这个**独立可选审批档**,而不是"auto-approval"档。
158
263
 
159
- 门禁**只在 `gatePresets` 列出的档位里生效**(默认 `['permissive']`,即本插件新增的那一档)。在其余任何档位
264
+ 门禁**只在 `gatePresets` 列出的档位里生效**(默认 `['permissive', 'permissive-full']`,即本插件新增的
265
+ 两个档位)。在其余任何档位
160
266
  (Read Only、Workspace Write、Full access、`custom`)里,门禁的判定流程**完全不运行**:不放行、不弹审批、
161
267
  不拒绝、不执行 P0 硬拒绝、不做黑名单关键词拦截,也不写审计事件——该档位自己的策略说了算。这正是重点所在:
162
268
  `danger-full-access` 的定义就是"全权限、不弹审批",用 ask 去覆盖它毫无意义(该档 `approval: never` 会让审批接缝
@@ -176,7 +282,8 @@ LLM 评定为 `safe` 且门禁自动放行的调用因此仍会弹出确认。
176
282
  开启 `llmAssist` 后,接收 LLM(自定义 OpenAI 兼容端点,或 DSH 宿主模型组——见上文)按结构化协议逐条评估 `ask`。**判定发生在门禁的 `tools/pre-execute` 瀑布内部、决策返回宿主之前**:`safe` 直接放行,审批面板根本不会出现;只有真正无法确定的判定才会弹到你面前。
177
283
 
178
284
  - `safe` → 自动放行(审计来源为 `classifier`),不弹面板。
179
- - `risky` + **硬风险类别**(`deletion`、`credential`、`remote`、`system`、`bulk`)→ **自动拒绝**、不弹面板;硬风险永不自动放行、也永不进入学习。
285
+ - `risky` + **硬风险类别**(`deletion`、`credential`、`remote`、`system`、`bulk`)→ **维持人工确认(ask)**。分类器**永不拒绝**:拒绝只属于确定性层(P0 硬拒绝、黑名单关键词、显式 `deny:` 规则),所以被误判的类别永远可协商,而不会变成无法申诉的封禁。硬类别与 `neutral` 仅保留一条关键区别:**永不进入学习**,因此反复确认也不可能把它沉淀成自动放行。
286
+ (拿模型的判断当拒绝依据是实测出来的问题:一条无害的 `git commit -F …` 被判 `remote`,直接自动拒绝——没有面板、也没有可重试的授权入口。)
180
287
  - `risky:neutral` → 若开启 `riskLearning`(设置卡片内,默认关闭),人工批准且真实执行的 neutral 风险会按 `tool|类别` 计数;计数达到 `riskThreshold`(默认 3)且新调用的操作指纹(命令词 + 目标基名)命中已确认样本时,**同一操作**自动放行。不同目标永不复用该放行。开启学习沉淀(`riskSediment`,默认开)后,满阈值 key 的确认样本会成为**确定性放行规则**:指纹精确命中即直接放行、无需再过 LLM——即使关闭 llmAssist 也继续生效;沉淀规则在设置卡片中可见、可管理(终止学习 / 删除样本)。
181
288
  - 超时(`riskTimeoutMs`,默认 20s,重试 1 次)、传输失败与协议外输出均维持原 `ask`——门禁绝不猜测。
182
289
 
@@ -191,7 +298,9 @@ LLM 评定为 `safe` 且门禁自动放行的调用因此仍会弹出确认。
191
298
  调用文本命中任一关键词(大小写不敏感子串)即直接拒绝,且先于白名单 / 授权 / LLM。黑名单在设置
192
299
  卡片中按列表查看与增删(预置条目带标签,可一键恢复预置);未设置或为空时应用预置列表——黑名单
193
300
  不会静默关闭。
194
- 该档位在权限选择器中**不显示图标**:选择器的图标只按三个内置值取,插件新增的档位是纯文字。
301
+ 「自动审查」与「自动审查(高权限)」在选择器里都画图标 —— 图标由安装指南里描述的那次宿主补丁
302
+ 从各自共用的文件沙箱档位复制而来(`workspace-write` 与 `danger-full-access`)。没有该补丁时,
303
+ 两个档位在所有界面上都是纯文字;它们的标签与门禁不受影响。
195
304
 
196
305
 
197
306
  ## CLI(独立 dry-run)
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/cordis.patch.yml CHANGED
@@ -14,9 +14,20 @@
14
14
  # stays `permissive`: it is the key `gatePresets` matches and the value the
15
15
  # permission projection reports, so only the label text is user-facing.
16
16
  #
17
- # The tier now carries a shield+eye icon in the composer glyph map (patched
18
- # into dsh-client-ui-conversation). The permissive glyph sits alongside the
19
- # three built-in ones so both the trigger button and the dropdown rows show it.
17
+ # The composer's permission picker draws an icon per preset from a CLOSED map
18
+ # inside @deepseek-ai/dsh-client-ui-conversation (`permissionGlyphs`), whose
19
+ # source states the rule: "host-configured names outside the design set get
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.
20
31
  #
21
32
  # The DSH bundle patch REPLACES the whole `permission.config.presets` map (it
22
33
  # does not merge per key), so this file MUST restate every tier that should
@@ -32,6 +43,24 @@
32
43
  # lost its implementation with that plugin, and this gate is inactive in it
33
44
  # (`gatePresets` defaults to `['permissive']`) — so it only offered a duplicate
34
45
  # tier that looked protected while nothing was.
46
+ #
47
+ # TWO tiers ship, because `sandbox` and `approval` are INDEPENDENT knobs and
48
+ # coupling them forced a bad trade:
49
+ #
50
+ # permissive sandbox: workspace-write approval: ask
51
+ # permissive-full sandbox: danger-full-access approval: ask
52
+ #
53
+ # `permissive` keeps the built-in file sandbox. That sandbox also denies the
54
+ # named pipes a child process needs to start — `git clone`, MSYS2/Cygwin
55
+ # `sh.exe` and ConPTY all fail with `Win32 error 5` under it. Because this gate
56
+ # is only active in the tiers listed in `gatePresets`, a user who wanted the
57
+ # gate had to accept that restriction to get it. `permissive-full` removes the
58
+ # coupling: same approval behaviour, no file-sandbox restriction.
59
+ #
60
+ # The gate itself only reads the preset NAME (see `src/preset.ts`), never the
61
+ # sandbox mode, so both tiers get the full P0–P4 chain. `approval: ask` is
62
+ # required on both — under `approval: never` the DSH seam rejects every ask
63
+ # before any answerer runs, which would degrade this gate's asks to passthrough.
35
64
  - id: permission
36
65
  config:
37
66
  presets:
@@ -46,6 +75,11 @@
46
75
  approval: ask
47
76
  name: 自动审查
48
77
  description: 独立审批档 —— 作用域内安全操作自动放行,危险/未知越界转人工确认(审批强度可在「设置 → 插件 → 自动审查」调整)。
78
+ permissive-full:
79
+ sandbox: danger-full-access
80
+ approval: ask
81
+ name: 自动审查(高权限)
82
+ description: 只由本插件的审批门过滤 —— 文件修改与子进程网络访问不再受沙箱限制,审批仍逐次生效,但没有系统沙箱兜底。
49
83
  danger-full-access:
50
84
  sandbox: danger-full-access
51
85
  approval: never
@@ -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