@netcatty/dsh-guard 0.0.0-stage → 0.2.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.
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +595 -2
- package/cordis.patch.yml +3 -0
- package/lib/audit-log.js +64 -0
- package/lib/client.js +679 -0
- package/lib/diff-view.js +155 -0
- package/lib/index.js +1958 -0
- package/lib/jsonc.js +140 -0
- package/lib/mask.js +22 -0
- package/lib/match.js +119 -0
- package/lib/params.js +105 -0
- package/lib/paths.js +140 -0
- package/lib/policy-edit.js +74 -0
- package/lib/presets.js +50 -0
- package/lib/preview.js +152 -0
- package/lib/shell.js +127 -0
- package/lib/snapshots.js +198 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +72 -4
- package/policy.json +289 -0
- package/policy.schema.json +317 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
首个功能完整的版本。0.1.0 只有 `path` / `bash` 两个规则面和基础审批,此后每一轮都在补面。
|
|
6
|
+
|
|
7
|
+
### 新增
|
|
8
|
+
|
|
9
|
+
- **规则面**:`pathRead` / `pathWrite` / `externalDirectory` / `params` / `toolParams` / `agents` / `argv` / `network` / `tools.<nameGlob>`;判定规则是同一面内最后命中者胜,跨面取最严。
|
|
10
|
+
- **审批**:三种模式 —— `approval`(DSH 原生)、`question`(DSH 问询卡片 + 文本 diff + 双栏链接)、`dialog`(自绘弹窗,红绿双栏、左右独立行号、中缝标记)。四档动作:允许一次 / 本会话允许 / 写入项目规则 / 拒绝。
|
|
11
|
+
- **数据**:写前快照与 `/guard-undo` 回滚;审计 `audit.jsonl`(凭据脱敏、按大小轮转)与 `/guard-log` 查询。
|
|
12
|
+
- **配置**:JSONC 解析、`policy.schema.json` 编辑器补全、`baseline` / `strict` / `off` 模板、设置页(开关、数值、模板、循环检测)。
|
|
13
|
+
- **防护**:循环检测(同调用重复 N 次转为 `ask`)、会话授权 TTL 与次数上限、`enforce: false` 试运行、`denyTools`。
|
|
14
|
+
- **命令**:`/guard-test` 试算判定、`/guard-policy` 热改策略、`/guard-log`、`/guard-undo`。
|
|
15
|
+
- **项目策略**:`.dsh/guard-policy.json` 沿目录树向上叠加,按 key 深合并,支持用 `null` 删除继承的规则。
|
|
16
|
+
|
|
17
|
+
### 修复
|
|
18
|
+
|
|
19
|
+
- 项目层 `tools.<name>` 曾整表替换而非合并,一条项目 `allow` 会顶掉全局整张表。
|
|
20
|
+
- `null` 删除规则曾被 `validate` 判为无效,导致整份项目文件被跳过。
|
|
21
|
+
- `{ kind: 'ask' }` 在完全权限档下被 DSH approval 自动放行;现由 `dialog` / `question` 自行处置。
|
|
22
|
+
- 审批卡片的相对链接被 Markdown 渲染器丢弃,改为绝对地址(`webBase`)。
|
|
23
|
+
- 数字输入框的步进按钮挤压数值、两个下拉的箭头位置不一致。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 netcatty
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,596 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @netcatty/dsh-guard
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
Windows-specific work still pending real-machine verification (install, run
|
|
447
|
+
`node --test`, check `%USERPROFILE%` expansion, drive-relative paths, and
|
|
448
|
+
PowerShell quoting). Windows separators and drive letters are normalized before
|
|
449
|
+
matching; matching is 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, and `SECRET|TOKEN|PASSWORD|API_KEY=...` values.
|
|
544
|
+
Only text blocks change; images and structured blocks pass through. Set
|
|
545
|
+
`redact: false` to disable.
|
|
546
|
+
|
|
547
|
+
The replaced text is what the session log stores — verified by inspection, not
|
|
548
|
+
just the UI.
|
|
549
|
+
|
|
550
|
+
## Configuration
|
|
551
|
+
|
|
552
|
+
```yaml
|
|
553
|
+
- id: guard
|
|
554
|
+
name: dsh-guard
|
|
555
|
+
config:
|
|
556
|
+
"*": allow
|
|
557
|
+
path:
|
|
558
|
+
"*": allow
|
|
559
|
+
"*.env": deny
|
|
560
|
+
"*.env.example": allow
|
|
561
|
+
pathRead:
|
|
562
|
+
"~/.cargo/registry/*": allow
|
|
563
|
+
externalDirectory:
|
|
564
|
+
"*": ask
|
|
565
|
+
"~/dev/*": allow
|
|
566
|
+
bash:
|
|
567
|
+
"*": allow
|
|
568
|
+
"sudo *": ask
|
|
569
|
+
"rm -rf /*": deny
|
|
570
|
+
params:
|
|
571
|
+
query:
|
|
572
|
+
"*password*": deny
|
|
573
|
+
command:
|
|
574
|
+
"git push*": ask
|
|
575
|
+
agents:
|
|
576
|
+
subagent: deny
|
|
577
|
+
main: allow
|
|
578
|
+
enforce: true
|
|
579
|
+
snapshots: true
|
|
580
|
+
snapshotRetain: 100
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
## Limits
|
|
584
|
+
|
|
585
|
+
- `ask` becomes a denial when the session has no approval channel (approval
|
|
586
|
+
policy `never`, or a non-interactive run).
|
|
587
|
+
- The bash scanner is a token heuristic, not a shell parser: quoted globs are
|
|
588
|
+
skipped, and shell expansion can hide a path. It catches the common accident,
|
|
589
|
+
not a determined bypass.
|
|
590
|
+
- `network` rules inspect URLs in text. They are not a proxy: traffic from a
|
|
591
|
+
process that resolves its own hostnames is out of reach.
|
|
592
|
+
- Glob patterns are bounded to `maxGlobStars` (default 2) unbounded `*` runs;
|
|
593
|
+
a third one is a validation error, because nested greedy quantifiers
|
|
594
|
+
backtrack exponentially on untrusted input. Write two rules instead.
|
|
595
|
+
- `preset:<name>` identity depends on the host recording `agentPreset` on the
|
|
596
|
+
session; without it only `main`/`subagent` are distinguishable.
|
package/cordis.patch.yml
ADDED