@netcatty/dsh-guard 0.2.1 → 0.2.3

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.3
4
+
5
+ ### 文档
6
+
7
+ - README 改为中文,英文版保留为 `README.en.md`
8
+ - 补充脱敏对连字符/点号键名与转义 JSON 的覆盖说明
9
+ - 更正自绘弹窗的行为:无在线客户端时立即拒绝,不再等超时
10
+
11
+ ## 0.2.2
12
+
13
+ ### 修复
14
+
15
+ - 脱敏漏掉 JSON 形态的复合键名:`"OXIDETERM_MCP_TOKEN": "..."`、`"sessionToken": "..."` 这类键名前/后缀带词的凭据值会原样落盘,只有精确等于 `token`/`secret`/`password` 等白名单键名的才被遮。规则改为键名任意位置命中 `token|api_key|apikey|secret|password|passwd|authorization|private_key|access_key` 即脱敏,与 `KEY=value` 那条口径对齐。
16
+
17
+ ## 0.2.1
18
+
19
+ Windows 实机验证后的一轮修复。
20
+
21
+ ### 修复
22
+
23
+ - `expand` 不识别 `~\` 前缀,`~\Documents` 被当成相对路径拼到 cwd 下,路径规则漏判。
24
+ - `~\` 展开后未归一化分隔符,POSIX 上会得到 `/home/u/\secret` 这种带反斜杠的路径,`~/secret*` 规则照样匹配不上。
25
+
26
+ ### 变更
27
+
28
+ - README 的 Windows 章节从「待实机验证」改为已验证:安装、`node --test`、`%USERPROFILE%` 展开、盘符相对路径、PowerShell 引号处理。
29
+
3
30
  ## 0.2.0
4
31
 
5
32
  首个功能完整的版本。0.1.0 只有 `path` / `bash` 两个规则面和基础审批,此后每一轮都在补面。
package/README.en.md ADDED
@@ -0,0 +1,598 @@
1
+ # @netcatty/dsh-guard
2
+
3
+ Allow / ask / deny gates for DeepSeek Harness tool calls.
4
+
5
+ ## Install
6
+
7
+ From npm:
8
+
9
+ ```sh
10
+ dsh plugin --profile web add @netcatty/dsh-guard
11
+ ```
12
+
13
+ From a checkout (development):
14
+
15
+ ```sh
16
+ git clone git@github.com:netcatty/dsh-guard.git ~/code/dsh-guard
17
+ dsh plugin --profile web add link:~/code/dsh-guard
18
+ ```
19
+
20
+ No build step: the plugin is plain ESM. On Windows use the clone path, e.g.
21
+ `dsh plugin --profile web add link:C:\code\dsh-guard`, then restart `dsh web`.
22
+ The repository ships `policy.json`, so rules are versioned with the code and
23
+ hot-reload while the plugin is mounted.
24
+
25
+ ## Data directory
26
+
27
+ Every file the plugin owns lives in one place:
28
+
29
+ ```
30
+ $DSH_HOME/dsh-plugins/dsh-guard/policy.json the live permission policy
31
+ $DSH_HOME/dsh-plugins/dsh-guard/audit.jsonl decision trail (rotates at auditMaxBytes)
32
+ $DSH_HOME/dsh-plugins/dsh-guard/status.json live status projection
33
+ $DSH_HOME/dsh-plugins/dsh-guard/snapshots/ pre-write file copies (one dir per call)
34
+ ```
35
+
36
+ `$DSH_HOME` defaults to `~/.dsh`. Files written by earlier versions
37
+ (`~/.dsh/guard-audit.jsonl`, `~/.dsh/guard-status.json`) move into that
38
+ directory once per mount; `auditPath` overrides the audit location.
39
+
40
+ **The live policy is the data-directory copy.** The repository ships a
41
+ `policy.json` too, but only as the seed: on the first mount of a fresh data
42
+ directory it is copied to `dsh-plugins/dsh-guard/policy.json`, and from then on
43
+ that copy is what runs. Editing the checkout therefore never changes behaviour,
44
+ and a `git pull` never overwrites your rules. Deleting the live file falls back
45
+ to the shipped seed until you write one again.
46
+
47
+ ## Editing the policy
48
+
49
+ The file is meant to be read and edited by hand, so it accepts everything strict
50
+ JSON refuses:
51
+
52
+ ```jsonc
53
+ {
54
+ "$schema": "./policy.schema.json", // editor completion + validation
55
+ "permission": {
56
+ "*": "allow",
57
+ // destructive commands always ask
58
+ "bash": {
59
+ "git push *": "ask", /* block comment too */
60
+ "rm -rf /*": "deny",
61
+ },
62
+ },
63
+ }
64
+ ```
65
+
66
+ - **Comments** — `//` to end of line, `/* … */` anywhere. A `//` inside a string
67
+ or URL is left alone.
68
+ - **Trailing commas** — one after the last rule of an object or array is fine.
69
+ - **`$schema`** — points an editor at `policy.schema.json` for completion and
70
+ inline validation; the loader strips it before reading rules.
71
+ - **Errors stay loud** — anything beyond trivia (unquoted keys, single quotes)
72
+ still fails, and a bad policy fails closed rather than silently applying half
73
+ of it.
74
+
75
+ Edits hot-reload in about a second. Verify one before trusting it:
76
+
77
+ ```
78
+ /guard-test bash {"command":"git push origin main"}
79
+ ```
80
+
81
+ Nothing else needs to run: no command writes the policy, no UI owns it.
82
+
83
+ ## Surfaces
84
+
85
+ | Surface | Applies to | Notes |
86
+ |---|---|---|
87
+ | `path` | every file tool + bash path tokens | cross-cutting, both directions |
88
+ | `pathRead` | `read`, `read_image`, `grep`, `glob` | direction-specific override |
89
+ | `pathWrite` | `write`, `edit` | direction-specific override |
90
+ | `externalDirectory` | paths outside the session cwd | defaults to allow |
91
+ | `bash` | command text | glob, last matching rule wins |
92
+ | `params` | any tool's arguments | key/value rules; covers tools with no path argument |
93
+ | `toolParams` | one tool's arguments | like `params`, scoped to a tool-name glob |
94
+ | `agents` | agent identity | `main` / `subagent` / `preset:<name>` |
95
+
96
+ Matching uses `*` (greedy, crosses `/`) and `?`. Within one surface the **last**
97
+ matching rule wins, so put broad rules first and exceptions after. Across
98
+ surfaces the most restrictive result wins: `deny` > `ask` > `allow`.
99
+
100
+ Path candidates include the literal argument, its cwd-resolved absolute form,
101
+ its cwd-relative form, and its symlink-resolved target.
102
+
103
+ ## Params
104
+
105
+ `params` gates any argument of any tool, which is what the path surfaces cannot
106
+ reach: `web_search` queries, `skill` names, MCP arguments that are not paths.
107
+
108
+ ```yaml
109
+ params:
110
+ query: # matches the `query` key at any depth
111
+ "*password*": deny
112
+ command:
113
+ "git push*": ask
114
+ ```
115
+
116
+ A key pattern matches either the full dotted path (`arguments.command`) or its
117
+ leaf name (`command`), and array members are addressable by their container
118
+ (`queries` covers `queries.0`, `queries.1`). Within one key's value map the last
119
+ matching rule wins; across keys the most restrictive result wins.
120
+
121
+ Set `patternMode: regex` to switch **this surface only** to regular expressions
122
+ (`^git\s+push`). Path and command rules stay glob: switching them wholesale
123
+ would break every `*.env` rule in an existing policy.
124
+
125
+ ## MCP tools
126
+
127
+ MCP tools are named `mcp__<server>__<tool>` and have no path argument of their
128
+ own, so they need their own surfaces. All three forms work:
129
+
130
+ ```jsonc
131
+ {
132
+ "permission": {
133
+ // 1. one server at a time, by tool-name glob
134
+ "tools": {
135
+ "mcp__chrome-devtools__*": { "*": "deny" }
136
+ },
137
+ // 2. one tool's arguments, without touching other tools that happen to
138
+ // use the same key (`fetch.url` stays free)
139
+ "toolParams": {
140
+ "mcp__chrome-devtools__navigate_page": {
141
+ "url": { "https://prod-*": "deny" }
142
+ }
143
+ },
144
+ // 3. refuse one tool outright, before any argument is looked at
145
+ "denyTools": ["mcp__github__delete_repo"]
146
+ }
147
+ }
148
+ ```
149
+
150
+ `tools` keys are matched as globs against the tool name, and a tool with no path
151
+ argument is matched by its own name — so `{ "*": "deny" }` means "every call to
152
+ this tool". `toolParams` is `params` narrowed to one tool: `{ tool: { key: {
153
+ value: state } } }`, with the same key matching (dotted path or leaf, array
154
+ containers included).
155
+
156
+ ## Agents
157
+
158
+ ```yaml
159
+ agents:
160
+ subagent: deny # delegated children never touch the filesystem
161
+ main: allow
162
+ preset:reader: ask # a read-only preset must still confirm writes
163
+ ```
164
+
165
+ The most specific identity wins: `preset:<name>` and `subagent:<name>` are
166
+ tried before `subagent`/`main`. A forked session carries a `parentSession` but is
167
+ **not** a subagent — identity follows the durable `origin: 'subagent'` marker.
168
+
169
+ ## Defaults
170
+
171
+ Sensitive paths are denied (`.env`, `*.env.*`, `id_rsa` family, `~/.ssh`,
172
+ `~/.aws`, `~/.gnupg`, `.netrc`, `.git-credentials`, `.npmrc`, `.pypirc`,
173
+ `.docker/config.json`, `.kube/config`, `~/.config/gh/hosts.yml`,
174
+ `~/.dsh/.credentials.yaml`); `*.env.example` is allowed.
175
+
176
+ `ask` rules only make sense while the session approval policy is `ask`; under
177
+ `never` an `ask` becomes a denial. Wrapper commands (`sudo`, `bash -c`, `eval`,
178
+ `xargs`, `find -exec`) and everything else default to `allow`; opt into
179
+ approval per surface:
180
+
181
+ ```yaml
182
+ wrappers: ask # or deny
183
+ bash:
184
+ "sudo *": ask
185
+ ```
186
+
187
+ ## Per-tool rules
188
+
189
+ `tools.<name>` applies path patterns to one tool on top of the cross-cutting
190
+ surfaces:
191
+
192
+ ```yaml
193
+ tools:
194
+ grep:
195
+ "*": deny
196
+ read:
197
+ "*/secrets/*": deny
198
+ ```
199
+
200
+ ## Session approvals
201
+
202
+ `sessionApprovals: true` remembers an ask that was actually approved (the tool
203
+ then ran successfully) and auto-allows the same rule pattern for the rest of
204
+ that session. It is off by default because it silently widens a one-time
205
+ approval; turn it on only when `ask` is your normal posture.
206
+
207
+ A grant is keyed by `(session, rule pattern)`, so approving `rm *` never widens
208
+ to `git push *`. Both limits are opt-in:
209
+
210
+ ```yaml
211
+ sessionApprovalTtlMs: 1800000 # 30 minutes; 0 = rest of the session
212
+ sessionApprovalMaxUses: 3 # 0 = unlimited
213
+ ```
214
+
215
+ ## Three-option approval
216
+
217
+ `approvalMode: question` replaces the two-outcome DSH approval for guard asks with
218
+ an explicit question carrying three choices: 允许一次 / 本会话允许 / 拒绝, plus
219
+ **写入项目规则** when the hit is a flat surface (`path`, `pathRead`,
220
+ `pathWrite`, `externalDirectory`, `bash`, `tools.<name>`). Choosing it appends
221
+ `"<pattern>": allow` to `<cwd>/.dsh/guard-policy.json` and applies immediately —
222
+ argv/network/params hits cannot be round-tripped into one rule and are refused
223
+ rather than guessed at.
224
+
225
+ A free-text "Other" answer becomes the denial reason the model sees, e.g.
226
+ `dsh-guard: 用户拒绝 "rm *":先用只读命令确认`. Only an explicit session choice
227
+ records a standing allow — nothing is widened silently. When no question answerer
228
+ is available the plugin falls back to the normal `ask` decision.
229
+
230
+ ### Self-drawn dialog (`approvalMode: dialog`)
231
+
232
+ `approvalMode: dialog` replaces the DSH question card with a dialog the plugin
233
+ draws itself. Because it is our own DOM, it shows the change **side by side**
234
+ instead of a text diff:
235
+
236
+ - two columns, red for the old line, green for the new one, unchanged rows grey
237
+ - `允许一次` / `本会话允许` / `写入项目规则` / `拒绝`
238
+ - one request at a time, 120 s timeout (`dialogTimeoutMs `) then deny
239
+
240
+ Mechanism: `tools/pre-execute` parks a Promise in an in-memory map, the browser
241
+ polls `GET /api/dsh-guard/pending`, and `POST /api/dsh-guard/decide` resolves it.
242
+ If no Client is connected the request waits for the timeout, so this mode assumes
243
+ the Web UI is open.
244
+
245
+ ### What the dialog shows
246
+
247
+ For `write` and `edit` the prompt carries a preview, not just the rule name:
248
+
249
+ ```
250
+ 规则:tools.write
251
+ 改写 src/app.ts:120 → 118 行(-2)
252
+ --- 原
253
+ - import { old } from './old'
254
+ - ...
255
+ +++ 新
256
+ + import { next } from './next'
257
+ + ...
258
+ ```
259
+
260
+ The current bytes are read from disk (capped at 128 KB) and diffed against the
261
+ incoming content: common prefix and suffix are trimmed, and the window around the
262
+ real change is shown with three lines of context. A one-word edit therefore shows
263
+ the line it touches, not the head of the file. `edit` shows the replaced fragment
264
+ instead.
265
+
266
+ The block is sent as a fenced Markdown code block, because the dialog renders
267
+ `detail` as Markdown and bare newlines would collapse into spaces.
268
+
269
+ Set `webBase` to the browser origin of this Web UI (for example
270
+ `http://192.168.1.10:3088`) so approval cards can link to the diff page. The
271
+ plugin cannot discover that origin by itself: `webApp` is not visible from the
272
+ plugin scope and the host process has no `DSH_WEB_URL`. Without it the card still
273
+ shows the text preview, just no link.
274
+
275
+ For `write` calls the card also carries **打开双栏对比**: a link to
276
+ `/api/dsh-guard/diff?id=…`, an HTML page that pairs old and new lines row by row
277
+ with red/green highlighting. Entries live in memory for 10 minutes (24 at most)
278
+ and are never written to disk — the card itself cannot hold a component, so this
279
+ is the only way to show a real two-column view.
280
+
281
+ It is read-only: a preview that cannot be built is simply omitted, and it never
282
+ blocks or delays the approval beyond one bounded read.
283
+
284
+ ## Dry run
285
+
286
+ `enforce: false` audits what the rules **would** have done and lets every call
287
+ through, with `dryRun: true` on the audit row:
288
+
289
+ ```yaml
290
+ enforce: false
291
+ ```
292
+
293
+ Use it to tune a policy without locking yourself out. `deny` is not exempt:
294
+ dry-run means dry-run for everything.
295
+
296
+ ## Snapshots and undo
297
+
298
+ A `write` or `edit` call destroys the previous bytes as soon as it lands. Before
299
+ letting one through, the plugin copies the target file into
300
+ `snapshots/<id>/` and records the id on the audit row, so a bad edit is
301
+ recoverable whether it failed or succeeded:
302
+
303
+ ```
304
+ /guard-undo roll back the most recent snapshot
305
+ /guard-undo list the last 10 snapshots (id, time, tool, files)
306
+ /guard-undo <id> roll back one specific snapshot
307
+ ```
308
+
309
+ Restore rewrites the saved bytes and deletes files that did not exist at capture
310
+ time. It does **not** silently skip: a file over `snapshotMaxBytes` or a
311
+ non-regular path is reported as skipped.
312
+
313
+ ```yaml
314
+ snapshots: true # default
315
+ snapshotMaxFiles: 20 # per call
316
+ snapshotMaxBytes: 262144 # per file; larger ones are marked skipped
317
+ snapshotRetain: 100 # snapshot dirs kept, oldest pruned
318
+ ```
319
+
320
+ Denied writes are never snapshotted (nothing was going to change), and a capture
321
+ failure never blocks the call — it just leaves nothing to undo.
322
+
323
+ ## Loop detection
324
+
325
+ A model stuck repeating one identical call is a different failure from a
326
+ dangerous one, and rules cannot see it: every individual call may be allowed.
327
+ The gate counts byte-identical calls (same tool, same arguments) inside a
328
+ window and asks once the count is reached:
329
+
330
+ ```yaml
331
+ repeatLimit: 5 # 0 = off (default)
332
+ repeatWindowMs: 60000 # rolling window
333
+ repeatAction: ask # allow | ask | deny
334
+ ```
335
+
336
+ `repeatLimit: 5` means the fifth identical call asks; the counter resets once
337
+ `repeatWindowMs` passes without a repeat. Different arguments are a different
338
+ call, so a loop that varies its input is not caught — this targets the stuck
339
+ case, not a determined adversary.
340
+
341
+ ## Blocking tools by name
342
+
343
+ `denyTools: [write, edit]` refuses those tools outright — a read-only posture.
344
+ It blocks calls rather than hiding schemas; hiding needs an agent-scoped
345
+ `ctx.tools.restrict`, which this plugin does not have from the profile root.
346
+
347
+ ## Config validation
348
+
349
+ Every rule state is validated (`allow`/`ask`/`deny`, map shapes, boolean
350
+ switches, array `denyTools`). An invalid configuration **fails closed**: every
351
+ tool call is denied with the validation errors in the reason. Fix the config and
352
+ the next mount restores normal operation.
353
+
354
+ ## Command rules
355
+
356
+ The shipped profile starts from pi's bash policy: destructive, publishing,
357
+ network, installer and build commands ask (three-option card in `question`
358
+ mode), while `rm -rf /` and `rm -rf ~*` deny. See `cordis.patch.yml` in the
359
+ profile for the full list.
360
+
361
+ Bash tokens are gated only when they look like paths (`/`, `~`, leading `.`,
362
+ denied basenames, or a `.env`/`.pfx`/`.p12` suffix), so identifiers such as
363
+ `process.env.HOME` are not mistaken for dotenv files.
364
+
365
+ ## argv surface (lexical shell decomposition)
366
+
367
+ `argv` rules run on a best-effort decomposition of a bash command line: split
368
+ on `|`, `||`, `;`, `&&`, `&` and newlines, then each simple command into
369
+ command word, argument tokens and redirect targets (quote/escape aware).
370
+ Command substitution, backticks and brace expansion stay opaque.
371
+
372
+ ```yaml
373
+ argv:
374
+ - match: { command: [rm], args: ["-rf", "/"] }
375
+ action: deny
376
+ - match: { pipeline: ["curl*|*sh", "wget*|*bash"] }
377
+ action: deny
378
+ - match: { command: [python3, node] }
379
+ action: ask
380
+ ```
381
+
382
+ Fields: `command` (any simple command word matches), `args` (every pattern
383
+ matches some token), `anyArg` (some token matches a pattern; `!` negates),
384
+ `pipeline` (command words joined by `|`). Redirect targets also go through the
385
+ write-direction path surface, so `echo x > secret.env` is denied by path rules.
386
+
387
+ ## Hot reload, audit, project policy, network
388
+
389
+ - **Hot reload**: `policy.json` is watched (1s poll). A valid edit replaces the
390
+ live policy without restarting; an invalid edit is rejected and logged.
391
+ - **Audit**: every decision appends one JSON line to
392
+ `$DSH_HOME/dsh-plugins/dsh-guard/audit.jsonl` (`auditPath` to change,
393
+ `audit: false` to disable). The `args` field is passed through the same
394
+ redactor as tool results before it is written, so the trail never becomes the
395
+ place a credential survives. The file rolls to `audit.jsonl.1` once it passes
396
+ `auditMaxBytes` (default 5 MiB).
397
+ - **Recent decisions**: the last 10 decisions stay in memory, newest first, and
398
+ appear as `recentDecisions` in `guard_status` and `status.json` — checking
399
+ what just happened never reads the audit file.
400
+ - **Project policy**: `<project>/.dsh/guard-policy.json` with the same
401
+ `permission` shape, merged over the global policy for sessions whose cwd is
402
+ that project (`projectPolicy: false` disables). Rules **stack from the
403
+ filesystem root down to the cwd**, so a monorepo can keep a baseline at
404
+ `<repo>/.dsh/guard-policy.json` and override one package at
405
+ `<repo>/pkg/.dsh/guard-policy.json`; the nearest file wins for the same key
406
+ (`projectPolicySearchUp: false` reads only the cwd). Changes are picked up by
407
+ mtime on the next call. Merging is **per key, not per table**: a project file
408
+ that sets `tools.write` for one path keeps every inherited write rule.
409
+
410
+ A project rule may be `null`, which **removes** the inherited rule instead of
411
+ setting one. This is the only way to relax a global rule for one project
412
+ without rewriting the global file:
413
+
414
+ ```jsonc
415
+ {
416
+ "permission": {
417
+ "bash": { "rm *": null }, // stop asking for rm in this project
418
+ "tools": { "write": null } // drop the whole inherited table
419
+ }
420
+ }
421
+ ```
422
+
423
+ In the global file `null` is accepted but has no effect — nothing is inherited
424
+ behind it, so the rule simply does not exist.
425
+ - **Network rules**: `network: [{ match: { domains: ["evil.example"] },
426
+ action: deny }]` matches hostnames from URLs in shell commands and tool
427
+ arguments (`url`, `endpoint`, …). This is static inspection, not a proxy.
428
+ - **yolo**: `yolo: true` turns every `ask` into allow; `deny` still blocks.
429
+ `sandboxAware` (default) does that automatically under `danger-full-access`
430
+ and `read-only`.
431
+
432
+ ## Windows
433
+
434
+ - Backslashes are normalized to `/` for matching; drive letters are preserved
435
+ (`C:\Users\…` matches `*.env`, `*/.ssh/*`, …).
436
+ - Matching is case-insensitive when `caseInsensitivePaths` is true (default:
437
+ `process.platform === 'win32'`). Set it explicitly on other platforms only if
438
+ the policy targets a case-insensitive filesystem.
439
+ - The `pwsh` (and `powershell`) tool reuses the same command surface as bash:
440
+ `bash` rules, `argv` decomposition, opaque-construct checks and redirect
441
+ targets all apply.
442
+ - In shell decomposition a backslash is a path separator on Windows rather than
443
+ an escape character.
444
+ - Tool basenames are split on both `/` and `\`.
445
+
446
+ Verified on a real Windows machine: install, `node --test`, `%USERPROFILE%`
447
+ expansion (both `~/` and `~\`), drive-relative paths, and PowerShell quoting.
448
+ Separators and drive letters are normalized before matching; matching is
449
+ case-insensitive on win32 by default.
450
+
451
+ ## Settings page
452
+
453
+ A **dsh-guard** tab appears under 设置 → 内置插件 (the shared plugin settings
454
+ surface, next to other plugins). It edits only the switches and scalar knobs:
455
+
456
+ | Control | Writes |
457
+ |---|---|
458
+ | `enforce` / `yolo` / `snapshots` / `audit` | the four booleans |
459
+ | `repeatLimit` / `repeatWindowMs` / `repeatAction` | loop detection |
460
+ | `snapshotMaxFiles` / `snapshotMaxBytes` / `snapshotRetain` | snapshot capacity |
461
+ | `auditMaxBytes` | audit rotation threshold |
462
+ | preset selector | `baseline` / `strict` / `off`, merged like `/guard-policy` |
463
+ | recent decisions | read-only, last 10 |
464
+ | snapshot list | read-only, last 8; roll back with `/guard-undo` |
465
+
466
+ Every write goes through `POST /api/dsh-guard/config`, which runs the same
467
+ `applyPatch` + `validate` + backup path as the command, so the panel cannot
468
+ produce a policy a hand edit could not. Rules themselves stay in `policy.json`:
469
+ they are nested maps with unlimited combinations, and a form covering them would
470
+ end up as a JSON textarea anyway.
471
+
472
+ The route fence is loopback-or-same-origin, the same one every plugin uses.
473
+
474
+ ## Commands
475
+
476
+ `policy.json` is the source of truth. `/guard-policy` writes it from the
477
+ composer; `/guard-test` and `/guard-undo` never touch it.
478
+
479
+ `/guard-policy` deep-merges a JSON fragment into the live policy, validates the
480
+ result, backs up the previous file, and lets the hot reload apply it:
481
+
482
+ ```
483
+ /guard-policy list
484
+ /guard-policy {"bash":{"rm *":"ask"}}
485
+ /guard-policy {"toolParams":{"navigate_page":{"url":{"https://prod-*":"deny"}}}}
486
+ /guard-policy {"bash":{"rm *":null}} # null removes a rule
487
+ /guard-policy preset list
488
+ /guard-policy preset baseline
489
+ ```
490
+
491
+ Templates are named fragments merged the same way, so they can only write what a
492
+ hand edit could. Three ship:
493
+
494
+ | Template | What it writes |
495
+ |---|---|
496
+ | `baseline` | 外部目录 ask、`rm`/`git push`/`sudo` ask、写 `*.env` deny、子代理 ask、`repeatLimit: 5` |
497
+ | `strict` | 命令与写入全部 ask、外部目录 deny、子代理 deny、`repeatLimit: 3` |
498
+ | `off` | `enforce: false`(只审计不拦截)、`repeatLimit: 0` |
499
+
500
+ A template merges, it does not replace: applying `strict` leaves your existing
501
+ `path` rules alone.
502
+
503
+ One grammar for every nesting depth, no separator to escape. A fragment that
504
+ fails validation is rejected and the file is left untouched. A rewrite
505
+ normalizes the file, so hand-written comments are lost; the previous text stays
506
+ at `policy.json.bak`, and for comment-preserving edits you can still edit the
507
+ file directly.
508
+
509
+ `/guard-test <tool> <json>` dry-evaluates one call against the live policy
510
+ without executing it — the same decision the gate would reach, including the
511
+ project overlay:
512
+
513
+ ```
514
+ /guard-test bash {"command":"git push origin main"}
515
+ bash: ask by "git push *" [bash]
516
+ /guard-test web_search {"queries":["my secret"]}
517
+ web_search: ask by "params queries=*secret*" [params]
518
+ ```
519
+
520
+ `/guard-undo [list|<id>]` rolls back a pre-write snapshot; see
521
+ [Snapshots and undo](#snapshots-and-undo).
522
+
523
+ `/guard-log [n]` prints the last n decisions (default 20, max 200) from
524
+ `audit.jsonl`, read as a bounded tail rather than parsing the whole file:
525
+
526
+ ```
527
+ /guard-log
528
+ 00:11:22 deny bash rm -rf ~*
529
+ 00:11:19 ask bash git push * [subagent]
530
+ 00:11:15 allow web_search params queries=*secret*
531
+ ```
532
+
533
+ All three are registered when the `commands` service is mounted; without it they
534
+ are simply absent. A command that takes arguments must declare `input`, or the
535
+ Client treats `/name args` as ordinary chat — the reason all three ship an
536
+ `input.hint`.
537
+
538
+ ## Redaction
539
+
540
+ `tools/post-execute` replaces credential-shaped text in every tool result before
541
+ it is persisted: `sk-*`/`rk-*`/`pk-*` keys, GitHub tokens (`ghp_`, `gho_`,
542
+ `github_pat_`), Slack tokens, AWS access keys, Google API keys, JWTs, `Bearer`
543
+ tokens, PEM private-key blocks, `SECRET|TOKEN|PASSWORD|API_KEY=...` values, and
544
+ JSON string fields whose key name contains `TOKEN` / `SECRET` / `PASSWORD` /
545
+ `API_KEY` / `AUTHORIZATION` anywhere (`"OXIDETERM_MCP_TOKEN": "..."` included).
546
+ Only text blocks change; images and structured blocks pass through. Set
547
+ `redact: false` to disable.
548
+
549
+ The replaced text is what the session log stores — verified by inspection, not
550
+ just the UI.
551
+
552
+ ## Configuration
553
+
554
+ ```yaml
555
+ - id: guard
556
+ name: dsh-guard
557
+ config:
558
+ "*": allow
559
+ path:
560
+ "*": allow
561
+ "*.env": deny
562
+ "*.env.example": allow
563
+ pathRead:
564
+ "~/.cargo/registry/*": allow
565
+ externalDirectory:
566
+ "*": ask
567
+ "~/dev/*": allow
568
+ bash:
569
+ "*": allow
570
+ "sudo *": ask
571
+ "rm -rf /*": deny
572
+ params:
573
+ query:
574
+ "*password*": deny
575
+ command:
576
+ "git push*": ask
577
+ agents:
578
+ subagent: deny
579
+ main: allow
580
+ enforce: true
581
+ snapshots: true
582
+ snapshotRetain: 100
583
+ ```
584
+
585
+ ## Limits
586
+
587
+ - `ask` becomes a denial when the session has no approval channel (approval
588
+ policy `never`, or a non-interactive run).
589
+ - The bash scanner is a token heuristic, not a shell parser: quoted globs are
590
+ skipped, and shell expansion can hide a path. It catches the common accident,
591
+ not a determined bypass.
592
+ - `network` rules inspect URLs in text. They are not a proxy: traffic from a
593
+ process that resolves its own hostnames is out of reach.
594
+ - Glob patterns are bounded to `maxGlobStars` (default 2) unbounded `*` runs;
595
+ a third one is a validation error, because nested greedy quantifiers
596
+ backtrack exponentially on untrusted input. Write two rules instead.
597
+ - `preset:<name>` identity depends on the host recording `agentPreset` on the
598
+ session; without it only `main`/`subagent` are distinguishable.