dsh-cmd-guard 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
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,306 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # dsh-cmd-guard
2
+
3
+ DeepSeek Harness 的命令结构解析护栏。
4
+
5
+ 不是一份黑名单,而是一个解析器:把命令字符串拆成分层结构、逐层判定,并且校验模型给出的意图说明与命令本身是否一致。
6
+
7
+ ---
8
+
9
+ ## 先说它不做什么
10
+
11
+ 这一节放在最前面,因为它比功能更重要。
12
+
13
+ - **不读取脚本内容。** 插件不做文件 IO,从不打开解释器要执行的文件。
14
+ - **不为文件内容背书。** `python scripts\a.py` 判为通过,意思是「路径来源可解释」,不是「这个文件是安全的」。
15
+ - **不是沙箱。** 不隔离进程、不限制系统调用、不做权限降级。
16
+ - **不联网。** 不发任何网络请求,不上报任何数据。
17
+ - **不是规则库。** 判据建立在命令结构上,名单只是其中一层。
18
+ - **不保证模型说清意图。** 详见下一节:官方 schema 已经强制 `description`,所以「必填」不是本事。
19
+
20
+ ---
21
+
22
+ ## 关于「意图绑定」的一次更正
23
+
24
+ 早期版本宣称的卖点是「模型必须说清为什么才能执行」。这个说法**是错的**。
25
+
26
+ 核对 0.2.x 的 `pwsh` 工具 schema 后确认:`description` 本来就是 `required: true`,且有非空校验(`expected a non-empty string`)。模型想通过只要填 `run command`。**把一个已经强制存在的字段再要求一遍,是装饰,不是控制。**
27
+
28
+ 现在做的是对说明的实质校验:
29
+
30
+ | 情形 | 判定 |
31
+ |---|---|
32
+ | 空 / 短于 8 字符 | ask(`WHY_MISSING` / `WHY_TOO_SHORT`) |
33
+ | 只写了动作、没写目的(`run command`、`do it`、`cleanup`) | ask(`WHY_GENERIC`) |
34
+ | 说明是命令原文的复述 | ask(`WHY_RESTATES_COMMAND`) |
35
+ | 说明声称是读取,命令却改状态 | ask(`WHY_CONTRADICTS`) |
36
+
37
+ 这比「必填」弱,但**它是真的**,而「必填」不是。
38
+
39
+ ### 一个必须承认的下限
40
+
41
+ `MIN_WHY_LENGTH = 8`,`sync upstream` 这类凑字数的说明就能解除 ask。
42
+
43
+ 而 DSH 的 `description` 是必填字段,真实宿主里几乎永远带着说明。所以这一层在实战中更像「拦完全空白的调用」,
44
+ 而不是「保证意图与动作一致」。**它能解除 ask,解除不了 deny** —— 这是有意的不对称。
45
+
46
+ ---
47
+
48
+ ## 残余风险
49
+
50
+ 以下限制是已知的、设计上接受的,写在这里而不是等问题发生后再解释。
51
+
52
+ ### 1. 工作区内脚本可以自己 shell out
53
+
54
+ `python scripts\build.py` 通过,但 `build.py` 内部完全可以 `os.system("del /s /q D:\\")`。
55
+ 插件审计的是命令结构,不是程序行为。这是纯函数解析的硬边界。
56
+
57
+ ### 2. 路径来源不等于内容可信
58
+
59
+ 「工作区内相对路径」判为通过,只说明这个路径可以从工作目录解释、没有 `..` 逃逸。
60
+ 如果工作区本身被写入过恶意文件,这条路径依然通过。**通过 = 可解释,不等于安全。**
61
+
62
+ ### 3. TOCTOU 只覆盖会话内
63
+
64
+ 写后即跑(`INTERP_RECENT_WRITE`)依赖会话内的写入账本。跨会话、或者由会话之外的工具写入的文件,不在覆盖范围内。
65
+
66
+ ### 4. 混淆检测是启发式
67
+
68
+ `base64 + exec`、`[char]` 拼接、dunder 动态导入这些信号命中即 ask。换一种编码方式就可能不命中。
69
+ 它是「提高攻击成本」,不是「保证拦截」。
70
+
71
+ ### 5. 链式命令不拆解
72
+
73
+ `a && b`、`a | b` 一律 ask,不分别判定两侧。这是有意的:判不了的结构就交给人,而不是假装判过了。
74
+
75
+ ### 6. 平台语法覆盖不均
76
+
77
+ `cmd` / `powershell` 语法覆盖较全。POSIX shell 的边角语法(heredoc、复杂展开)识别较弱,通常会落到 ask,但不会是 deny。
78
+ Windows 盘符根与 POSIX 根 `/` 都在 deny 覆盖内,其他路径形态没有穷举。
79
+
80
+ ### 7. 未知工具的识别靠形状启发式
81
+
82
+ `bash` / `pwsh` 之外的执行工具,靠「参数里有没有命令行特征」判断。特征是启发式的:
83
+ 带可执行扩展名的路径、破坏性动词、`/s` `-rf` `--force`、管道接 shell、解释器前缀、命令拼接符。
84
+ 纯路径(如 `D:/a.txt`)**不算**命令 —— 这一条是实测出的误判修正,不是推测。
85
+ 反过来,一个形状极不典型的自定义执行工具可能识别不到。所以未知工具命中时判 ask,不判 deny。
86
+
87
+ ### 8. `ask` 在没有审批服务时会退化为拒绝
88
+
89
+ `PreToolDecision` 的 `ask` 只有在审批服务返回 `allowed-once` 时才继续。
90
+ 未组合审批服务、或调用没有 agent 时,框架把它降级为拒绝。
91
+ 也就是说**没装审批 UI 的环境里,ask 等同于 deny**,护栏会显著变严。
92
+
93
+ ### 9. 嵌套引号在第二层会失去结构
94
+
95
+ 三层以上的 `bash -c "bash -c \"bash -c 'rm -rf /'\""`,引号在分词阶段就被剥掉,第二层之后
96
+ 不再具备「主机 + 标志」的形状,第三层的 `rm -rf /` 识别不到。
97
+
98
+ 实测这类三层套娃判定为 **ask 而不是 deny**。手工验证过,那串命令实际执行也到不了
99
+ `rm -rf /` —— 第三层的参数已经不是一条命令。因此没有为它重写分词器。
100
+ **多引号嵌套的结论是 ask,不是 deny。**
101
+
102
+ ### 10. capability 层仍然是一份「已知安全」的清单
103
+
104
+ 反转白名单降低了漏判,但没有消除:判定依据仍是信号表(目标敏感度、参数形状)。
105
+ **没有任何信号命中的新程序会静默通过。**
106
+
107
+ 豁免集已被实测推翻过六次:`sort -o` 写文件、`rg --pre` 跑程序、`unzip -d` 解包到系统目录、
108
+ `setx` / `attrib` 改环境与属性、`net` / `schtasks` 停服务与删任务。
109
+ 共同教训只有一条:**「看着只读」不是「只读」**。新加进豁免集的工具,第一件事应该是撞它最坏的那个用法。
110
+
111
+ ### 11. Unicode 折叠只覆盖 NFKC 能归一的部分
112
+
113
+ 全角拉丁、零宽空格、软连字符、NUL 在规范化阶段被折叠或剔除,这是实测撞出来的四种。
114
+ NFKC 折叠不了的等价写法(同形异码的西里尔字母、部分组合字符序列)不在覆盖内。
115
+
116
+ ---
117
+
118
+ ## 八层结构
119
+
120
+ 顺序固定,命中即返回,后面的层不能软化前面的判定。
121
+
122
+ | 层 | 判定 | 拦什么 |
123
+ |---|---|---|
124
+ | `entry` | ask | 未知执行工具带着命令行形状的参数;命令参数键名漂移(不是 `command`) |
125
+ | `rules` | **deny** | 卷格式化、递归删除系统根或文件系统根(`C:\`、`/`)、删除机器级注册表 hive |
126
+ | `capability` | ask | 未知程序的能力信号:指向系统或凭证位置、递归与强制同时出现、代码作为参数、目标由旗标值给出、携带破坏性动词。判断依据是目标与参数形状,不是程序名 |
127
+ | `split` | ask | `\|` `&&` `&` `;` 重定向、`^\|` 转义管道、进程替换 `<(...)` |
128
+ | `obfusc` | ask | base64 解码进 eval、`[char]` 拼接、dunder 动态导入、原生加载器、长十六进制串、词内插入 `^` |
129
+ | `unwrap` | ask | 嵌套 shell(`cmd /c`、`powershell -Command`)、`--` 参数透传、`git -c k=v` 配置注入 |
130
+ | `interp` | ask | 解释器的内联体、stdin、`-m` 模块、越界路径、临时目录、写后即跑 |
131
+ | `why` | ask | 意图说明是占位符、复述命令、或与命令动作矛盾 |
132
+
133
+ **deny 只有一个出口。** 非 `rules` 层若返回 deny,会被降级为 ask —— 这条不变式在 `verify.ts` 里是代码,在测试里是断言。
134
+
135
+ `entry` 放在第一位,是因为它出过一次真实的近失:早期版本按 `run_command` + `cmd` 接线,而实际工具是 `bash` / `pwsh`,参数是 `command`。那种护栏**装得上、启得动、一条都拦不住**,比装不上更糟。
136
+
137
+ ---
138
+
139
+ ## 解释器判据:按路径来源,不按文件是否存在
140
+
141
+ 早期版本按「有没有文件参数」判定,这是个完整绕过:写个文件,再执行它。
142
+
143
+ 现在的判据:
144
+
145
+ | 情形 | 判定 |
146
+ |---|---|
147
+ | 工作区内相对路径,无 `..` 逃逸 | pass |
148
+ | 内联程序体 `-c` / `-e` / `--eval` | ask |
149
+ | stdin / `con` / `/dev/stdin` | ask |
150
+ | `-m <module>`(模块选择器,不是路径) | ask |
151
+ | 工作区外绝对路径 | ask |
152
+ | `%TEMP%` / `AppData\Local\Temp` | ask |
153
+ | 任意 `..` 越界 | ask |
154
+ | 本会话刚写入、立即执行 | ask |
155
+
156
+ `python -v` 之类的版本查询是唯一不需要检查路径就放行的形态。
157
+
158
+ ---
159
+
160
+ ## 意图说明的判定
161
+
162
+ 同一个命令字符串,说明不同,判定不同:
163
+
164
+ ```
165
+ del D:\temp\a.txt description: "run command" → ask(泛词)
166
+ del D:\temp\a.txt description: "del D:\temp\a.txt" → ask(复述)
167
+ del D:\temp\a.txt description: "读一下确认没被占用" → ask(矛盾:声称读,实际删)
168
+ del D:\temp\a.txt description: "删掉构建残留,产物已重新生成" → pass
169
+ ```
170
+
171
+ **说法不能兑换 deny**:`rd /s /q C:\` 配上再详细的理由也还是 deny。
172
+
173
+ ---
174
+
175
+ ## 四态审批
176
+
177
+ 框架的 `PreToolDecision` 是四态,不是三态:
178
+
179
+ | 结果 | 含义 | 本插件是否产出 |
180
+ |---|---|---|
181
+ | `allow` | 放行 | 由 `verify()` 的 pass 映射为 `null`(延迟给 `next()`) |
182
+ | `deny` | 拒绝,物化为模型可见的错误结果 | 是 |
183
+ | `cancel` | 规范化取消,不呈现为策略拒绝 | **否**——这是操作者按停止的意思,不是插件有权做的陈述 |
184
+ | `ask` | 转人工,仅 `allowed-once` 才继续 | 是 |
185
+
186
+ pass 映射成 `null` 而不是 `{kind:'allow'}` 是有意的:`pre-execute` 是 waterfall,返回决策会跳过之后挂载的所有监听器。
187
+
188
+ ---
189
+
190
+ ## 挂载
191
+
192
+ 一个包,一行 `insert`,两个模块:
193
+
194
+ ```yaml
195
+ - insert:
196
+ - id: cmd-guard
197
+ name: dsh-cmd-guard
198
+ ```
199
+
200
+ 实际接线(已对照 0.2.x 类型声明,并在真实宿主中运行时验证):
201
+
202
+ | 用途 | 挂载点 | 签名 |
203
+ |---|---|---|
204
+ | 硬拒绝 | `ctx.tools.guard` | `(exec) => string \| undefined`,无 allow 出口 |
205
+ | ask / deny | `ctx.on('tools/pre-execute')` | waterfall `(exec, next) => PreToolDecision` |
206
+ | 写入账本 | `ctx.on('tools/post-execute')` | waterfall `(exec, result, next) => PostToolDecision` |
207
+
208
+ - `guard` 在所有 `pre-execute` 监听器之后、审批之后运行,所以拒了翻不了案,监听器顺序也改不回来
209
+ - `guard` 只返回拒绝理由,**不返回 allow**:它没有授予权限的能力,假装有会让护栏误以为自己放行了什么
210
+ - 决策形状在运行时不合法时**fail closed**(判 deny)。形状写错的护栏不会报错,只会静默失效,所以宁可拒
211
+
212
+ `changeset`(自举治理模块)默认关闭,需要 `danger-full-access`,不应该是装上就白送的能力。
213
+
214
+ ---
215
+
216
+ ## 测试
217
+
218
+ ```bash
219
+ npm test
220
+ ```
221
+
222
+ **292 条断言全绿**,其中 256 条是命令语料,分 8 批。
223
+
224
+ 语料是按轮次冻结的,每一批对应一次「换个角度攻击」,而不是一次随手补样:
225
+
226
+ | 批次 | 条数 | 撞的角度 |
227
+ |---|---|---|
228
+ | `attacks` | 37 | 各层各自的攻击形态,含本工作区真实发生过的 `--` 参数透传(`U-003`) |
229
+ | `attacks-2` | 6 | 语料外抽查出的漏杀(`git push --force`、`rm -rf /` 曾是静默通过) |
230
+ | `attacks-3` | 10 | 工具身份:`bash` / `pwsh` / 未知工具 / 键名漂移 / 意图说明的实质校验 |
231
+ | `attacks-4` | 22 | 包装器与变形(`env` / `nice` / `sudo`、`$IFS`、拼接) |
232
+ | `attacks-5` | 44 | 命令头可以是任何程序 |
233
+ | `attacks-6` | 48 | 豁免集本身:逐条去撞它最坏的用法 |
234
+ | `attacks-7` | 51 | `why` 能否解锁不可恢复删除 |
235
+ | `attacks-8` | 38 | 工具身份、路径越界、Unicode、结构化嵌套 |
236
+
237
+ ### 语料里的 `layer` 是什么
238
+
239
+ `layer` 是**攻击面分桶**,不是这条命令最终被哪一层拦下。runner 故意不校验它(见 `test/guard.test.ts` 顶部注释):同一条命令常由多层可达,钉死层名会把一次合理的重排变成测试失败,而真正不能漂移的是 `verdict`。
240
+
241
+ 因此「声明 `split`、实测报 `rules`」不是错标:`split` 层解开外链后会把内层 finding 原样返回、保留内层层名,这是 `verify` 的「只有 `rules` 可以 deny」不变量所依赖的行为。`layer: "clean"` 表示对照组,预期不产生任何 finding。
242
+
243
+ `layer` 唯一的硬约束是覆盖度:`entry` / `rules` / `capability` / `split` / `unwrap` / `interp` / `obfusc` / `why` 每层都必须有案例。`schema` 字段不描述行为,八批统一为 `3`,并由测试断言保持一致。
244
+
245
+ 另有结构断言与挂载层断言:
246
+
247
+ - 语料完整性:id 唯一、层名合法、**deny 只来自 `rules`**
248
+ - 意图绑定对照组;`U-003` 永不 pass;说法不能兑换 deny;不可恢复删除不接受 `why`
249
+ - 挂载层:`command` / `description` 映射、未知工具映射、命令形状识别、pass 必须延迟、
250
+ ask / deny 决策形状合法、**`cancel` 永不产出**、硬拒绝只拒不放
251
+ - 语料 runner 表达不了的三种形状(数据键藏命令、双命令键、工具名不洗白命令)以单元测试形式钉住
252
+
253
+ ---
254
+
255
+ ## 明确不拦的
256
+
257
+ 护栏的价值一半在拦了什么,一半在**没拦什么**。以下是已知的放行,不是漏判:
258
+
259
+ | 输入 | 判定 | 原因 |
260
+ |---|---|---|
261
+ | `git push --force` + 8 字符以上说明 | pass | 远端历史改写不属本机文件系统;只拦无说明的静默调用 |
262
+ | `rm -rf ./build` | pass | 工作区内相对路径,可重建 |
263
+ | 文件读写工具的参数 | 不覆盖 | 只接命令行类工具;`content` 是数据,不是命令 |
264
+ | 未来的 `0.2.5-rc.1` 等预发布宿主 | 装不上 | semver 无法通配任意 `-rc.N`,需人工加范围 |
265
+
266
+ `why` 下限 8 字符,所以「能凑出一句说明」即可解除 ask,但**解除不了 deny**。
267
+
268
+ ---
269
+
270
+ ## 兼容性
271
+
272
+ ```
273
+ peerDependency: @deepseek-ai/dsh ">=0.2.0-0 <0.3.0 || >=0.2.2-rc.0 <0.3.0"
274
+ ```
275
+
276
+ DSH 当前版本线全部是预发布(`0.2.x-rc.*`),`^0.2.0` 匹配不到任何已发布版本,会直接装不上。
277
+
278
+ 预发布还有第二层坑:semver 规定,预发布版本只在范围里显式出现同一 `major.minor.patch` 的预发布标记时才匹配。
279
+ 所以 `>=0.2.0-0 <0.3.0` 对 `0.2.2-rc.1` 返回 **false**。
280
+ 0.1.0 里「本范围实测基线为 `0.2.2-rc.1`」那句是错的 —— 实测安装会 ERESOLVE。这句话在 0.1.1 里删掉了,
281
+ 换成两段并集:`>=0.2.2-rc.0` 显式放行 0.2.2 的预发布线。
282
+
283
+ 实测记录(宿主侧已装 `0.2.2-rc.1`):
284
+
285
+ | 项 | 结果 |
286
+ |---|---|
287
+ | `npm i` 旧范围 `>=0.2.0-0 <0.3.0` | **ERESOLVE**,失败 |
288
+ | `npm i` 新范围(0.1.1) | `added 20 packages`,通过 |
289
+ | 真实宿主运行时(cordis + systemPrompt + tools) | 8 条命令:deny 3 / ask 1 / pass 4 |
290
+ | `bash -c "rm -rf /"` | deny `RULE_RECURSIVE_SYSTEM_DELETE`,套壳被穿透 |
291
+ | `git push --force` + 无说明 | ask(`WHY_MISSING`) |
292
+
293
+ 证据等级:**宿主组件级真机验证** —— 真实 `cordis Context`、真实 `dsh-system-prompt`、真实 `dsh-tools`,
294
+ 加载的是从 npm 安装的包,不是本地源码,也不是 mock。
295
+ 未覆盖:`dsh plugin add` 端到端(本机无 pnpm),即「用户怎么装」这一环没有实跑。
296
+
297
+ 这个范围的上限就是 semver 本身:未来任何新的 `0.2.x-rc.N` 想被匹配,都得再往范围里加一段。
298
+ 它不能靠推理一次写死。
299
+
300
+ 零运行时依赖。无网络请求。
301
+
302
+ ---
303
+
304
+ ## License
305
+
306
+ MIT
@@ -0,0 +1,17 @@
1
+ # dsh-cmd-guard bundle patch
2
+ #
3
+ # Exactly one insert line on purpose. Mounting the same `name` twice would
4
+ # load two independent module instances, each holding its own guard state.
5
+ # Both modules (guard + changeset) are wired inside a single `apply`, gated
6
+ # by `config`.
7
+
8
+ - insert:
9
+ - id: cmd-guard
10
+ name: dsh-cmd-guard
11
+ config:
12
+ guard:
13
+ enabled: true
14
+ changeset:
15
+ # Requires danger-full-access. Off by default so a stock profile
16
+ # never gains write-to-self capability on install.
17
+ enabled: false
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Layer 2: capability inference - the answer to "judge the unknown".
3
+ *
4
+ * Every layer before this one asks a question with no closed answer: "which
5
+ * program is this?" Four rounds of out-of-corpus probes returned a fresh batch
6
+ * of misses each time, and every miss had the same shape - a program, wrapper
7
+ * or spelling that had not been listed. Unlisted `busybox`, unlisted `strace`,
8
+ * unlisted `php -r`, a verb behind a path. Enumerating programs does not
9
+ * converge, because the space of programs is open and the list is finite.
10
+ *
11
+ * This layer asks a different question, and answers it without knowing the
12
+ * program at all: what can this line do to the machine? Each signal below is a
13
+ * property of the target or of the argument shape, not of the tool's name. An
14
+ * unheard-of binary pointing at C:\Windows with -r -f reads the same way as
15
+ * `rm`, because the thing being read is the intent, not the name.
16
+ *
17
+ * The inversion is the point. The known list used to be a detection list: a
18
+ * tool was judged only if recognised. It is now an exemption list: a tool is
19
+ * exempted only if recognised, and everything else is judged on capability.
20
+ *
21
+ * An exemption is not a claim of safety - it is a claim that this layer adds
22
+ * nothing. `echo`'s tail is data, and a delete it is merely printing is judged
23
+ * by the layer that owns the shell syntax, not here.
24
+ *
25
+ * ask only, never deny. A capability signal is suspicion, and deny stays with
26
+ * the hard blacklist, which is the only place a false positive is
27
+ * unrecoverable.
28
+ */
29
+ import type { Finding, GuardInput, GuardState } from './types.js';
30
+ export declare function check(input: GuardInput, _state: GuardState): Finding | null;