dsh-perm-gate 2.1.2 → 3.0.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 (58) hide show
  1. package/CHANGELOG.ja.md +82 -0
  2. package/CHANGELOG.ko.md +80 -0
  3. package/CHANGELOG.md +210 -0
  4. package/INSTALL.ja.md +14 -8
  5. package/INSTALL.ko.md +15 -9
  6. package/INSTALL.md +16 -8
  7. package/INSTALL.zh.md +12 -7
  8. package/README.ja.md +19 -6
  9. package/README.ko.md +20 -7
  10. package/README.md +35 -4
  11. package/README.zh.md +13 -4
  12. package/docs/baseline-guard.md +56 -0
  13. package/docs/plan-rules-to-settings.md +217 -0
  14. package/docs/rules-format.en.md +294 -0
  15. package/docs/rules-format.md +279 -0
  16. package/docs/ui-gap-analysis-and-branch-permissions.md +338 -0
  17. package/dsh.plugin.json +2 -2
  18. package/examples/permissions.example.yaml +16 -1
  19. package/lib/allowlist.d.ts +27 -0
  20. package/lib/allowlist.js +103 -1
  21. package/lib/cli.js +11 -14
  22. package/lib/client.js +484 -6
  23. package/lib/client.js.map +1 -1
  24. package/lib/command-dispatcher.d.ts +22 -0
  25. package/lib/command-dispatcher.js +97 -0
  26. package/lib/command-semantics.d.ts +62 -0
  27. package/lib/command-semantics.js +6 -0
  28. package/lib/compiler.d.ts +11 -0
  29. package/lib/compiler.js +16 -0
  30. package/lib/config.d.ts +73 -12
  31. package/lib/config.js +56 -14
  32. package/lib/dry-run.d.ts +87 -0
  33. package/lib/dry-run.js +110 -0
  34. package/lib/engine.js +52 -2
  35. package/lib/evaluate.js +76 -2
  36. package/lib/events.d.ts +56 -2
  37. package/lib/events.js +92 -0
  38. package/lib/index.js +106 -26
  39. package/lib/parsers/git.d.ts +43 -0
  40. package/lib/parsers/git.js +404 -0
  41. package/lib/parsers/shell-cmds.d.ts +32 -0
  42. package/lib/parsers/shell-cmds.js +293 -0
  43. package/lib/preset.d.ts +13 -0
  44. package/lib/preset.js +17 -0
  45. package/lib/risk.d.ts +17 -3
  46. package/lib/risk.js +17 -3
  47. package/lib/rule-chain.js +13 -22
  48. package/lib/rule-dims.d.ts +35 -0
  49. package/lib/rule-dims.js +36 -0
  50. package/lib/rule.d.ts +36 -1
  51. package/lib/rule.js +56 -8
  52. package/lib/rules-view.d.ts +52 -0
  53. package/lib/rules-view.js +126 -0
  54. package/lib/runtime.d.ts +121 -7
  55. package/lib/runtime.js +204 -25
  56. package/lib/shell.d.ts +14 -0
  57. package/lib/shell.js +17 -0
  58. package/package.json +19 -2
@@ -0,0 +1,338 @@
1
+ # dsh-perm-gate UI 缺口分析与分支级权限可行性
2
+
3
+ > **日期**: 2026-09-16
4
+ > **状态**: 草案
5
+ > **相关讨论**: PerryLink dsh-auto-review 对比分析
6
+
7
+ ---
8
+
9
+ ## 一、现状:后端能力完整,前端 UI 缺失
10
+
11
+ ### 后端已支持的完整 YAML 规则格式
12
+
13
+ `rules.yml` 支持 10 个维度的分层权限配置:
14
+
15
+ ```yaml
16
+ permissions:
17
+ deny:
18
+ - tools: [shell, pwsh]
19
+ command: [rm#recursive, rm#force]
20
+ reason: "destructive system command"
21
+
22
+ - args: ["--force"]
23
+ level: deny
24
+ reason: "Force flag detected"
25
+
26
+ allow:
27
+ - command: [grep, glob, read]
28
+ reason: "permissive allowlist"
29
+
30
+ - network:
31
+ domains: [github.com, api.github.com]
32
+ reason: "common dev hosts"
33
+
34
+ ask:
35
+ - command: [git]
36
+ args: ["push"]
37
+ reason: "Git push requires confirmation"
38
+ ```
39
+
40
+ **VALID_KEYS**: `tools`, `command`, `args`, `paths`, `params`, `absent`, `agents`, `when`, `argv`, `network`, `action`, `reason`, `enabled`
41
+
42
+ ### 前端 UI 面板实际暴露的内容
43
+
44
+ ```
45
+ ┌─────────────────────────────────────────────┐
46
+ │ 自动审查 (permissive) 开关 │
47
+ ├─────────────────────────────────────────────┤
48
+ │ 策略选项 │
49
+ │ ☑ trustAutoAllow │
50
+ │ ☐ alwaysConfirm │
51
+ │ ☐ llmAssist │
52
+ ├─────────────────────────────────────────────┤
53
+ │ 允许列表 (allowlist) │
54
+ │ [简单字符串列表,支持批量 textarea 编辑] │
55
+ │ ⚠️ 只支持前缀匹配,不支持 YAML 分层规则 │
56
+ ├─────────────────────────────────────────────┤
57
+ │ 拒绝关键词 (denyKeywords) │
58
+ │ [同样只支持简单字符串列表] │
59
+ └─────────────────────────────────────────────┘
60
+ ```
61
+
62
+ ### 缺失的 UI 能力
63
+
64
+ | 功能 | 后端支持 | UI 支持 | 状态 |
65
+ |------|---------|---------|------|
66
+ | YAML 规则编辑器 | ✅ | ❌ | 需开发 |
67
+ | 维度选择器 (tools/command/args/network 等) | ✅ | ❌ | 需开发 |
68
+ | 规则级别选择 (deny/allow/ask) | ✅ | ❌ | 需开发 |
69
+ | 复合条件编辑 (AND/OR 逻辑) | ✅ | ❌ | 需开发 |
70
+ | 规则测试/预览 | ✅ (CLI dry-run) | ❌ | 需开发 |
71
+ | 规则导入/导出 | ❌ | ❌ | 需开发 |
72
+
73
+ ---
74
+
75
+ ## 二、UI 补全计划草案
76
+
77
+ ### Phase 1: 最小可行方案 (MVP)
78
+
79
+ **目标**: 在 UI 中暴露完整的 YAML 规则编辑能力
80
+
81
+ #### 1.1 YAML 编辑器集成
82
+
83
+ - 集成 Monaco Editor 或 CodeMirror 支持 YAML 语法高亮
84
+ - 支持 `rules.yml` 文件的实时编辑和保存
85
+ - 添加 YAML 语法校验和错误提示
86
+ - 添加规则格式校验(维度名称、值类型等)
87
+
88
+ #### 1.2 规则模板系统
89
+
90
+ - 提供常用规则模板(git push、文件删除、网络访问等)
91
+ - 支持从模板快速创建规则
92
+ - 模板库可扩展
93
+
94
+ #### 1.3 规则测试功能
95
+
96
+ - 添加"测试规则"按钮
97
+ - 输入命令字符串,预览规则匹配结果
98
+ - 显示哪些维度匹配、哪些不匹配
99
+ - 复用 CLI dry-run 逻辑
100
+
101
+ ### Phase 2: 可视化规则编辑器
102
+
103
+ **目标**: 提供图形化的规则创建界面,降低 YAML 编写门槛
104
+
105
+ #### 2.1 维度化规则编辑器
106
+
107
+ ```
108
+ ┌─────────────────────────────────────────────┐
109
+ │ 新建规则 │
110
+ ├─────────────────────────────────────────────┤
111
+ │ 规则级别: [▼ deny] │
112
+ │ 原因: [输入框] │
113
+ ├─────────────────────────────────────────────┤
114
+ │ 匹配条件 (AND 逻辑): │
115
+ │ [+ 添加条件] │
116
+ │ │
117
+ │ ┌─────────────────────────────────────┐ │
118
+ │ │ 维度: [▼ command] 模式: [git] │ │
119
+ │ │ 删除 │ │
120
+ │ └─────────────────────────────────────┘ │
121
+ │ │
122
+ │ ┌─────────────────────────────────────┐ │
123
+ │ │ 维度: [▼ args] 模式: [--force] │ │
124
+ │ │ 删除 │ │
125
+ │ └─────────────────────────────────────┘ │
126
+ │ │
127
+ │ ┌─────────────────────────────────────┐ │
128
+ │ │ 维度: [▼ network] 域名: [example.*] │ │
129
+ │ │ 删除 │ │
130
+ │ └─────────────────────────────────────┘ │
131
+ ├─────────────────────────────────────────────┤
132
+ │ [预览 YAML] [保存规则] │
133
+ └─────────────────────────────────────────────┘
134
+ ```
135
+
136
+ #### 2.2 规则分组管理
137
+
138
+ - 支持按用途分组(安全规则、工作流规则等)
139
+ - 支持规则启用/禁用开关
140
+ - 支持规则排序(优先级)
141
+
142
+ #### 2.3 规则冲突检测
143
+
144
+ - 检测规则间的冲突(deny vs allow)
145
+ - 提示规则冗余(被更宽泛的规则覆盖)
146
+ - 建议规则优化
147
+
148
+ ### Phase 3: 高级功能
149
+
150
+ **目标**: 提供生产级规则管理能力
151
+
152
+ #### 3.1 规则版本控制
153
+
154
+ - 规则变更历史
155
+ - 支持回滚到历史版本
156
+ - 与 Git 集成(rules.yml 版本追踪)
157
+
158
+ #### 3.2 规则导入/导出
159
+
160
+ - 支持从其他 perm-gate 实例导入规则
161
+ - 支持导出规则包
162
+ - 支持从模板库导入
163
+
164
+ #### 3.3 规则审计日志
165
+
166
+ - 记录规则创建/修改/删除操作
167
+ - 记录规则匹配历史
168
+ - 支持审计日志查询
169
+
170
+ ---
171
+
172
+ ## 三、分支级权限可行性分析
173
+
174
+ ### 当前 YAML 能否实现 git push 分支级权限?
175
+
176
+ #### 3.1 args 维度的匹配逻辑
177
+
178
+ ```typescript
179
+ // evaluate.ts 第 76-81 行
180
+ // args dimension: any token matches any args glob.
181
+ if (rule.args.length > 0) {
182
+ const tokens: string[] = []
183
+ for (const cmd of commands) tokens.push(...cmd.args, ...cmd.redirects)
184
+ tokens.push(...pathCands, ...urlCands)
185
+ if (!tokens.some((tok) => rule.args.some((g) => g.re.test(tok)))) return false
186
+ }
187
+ ```
188
+
189
+ **关键发现:args 是 OR 逻辑,不是 AND 逻辑。**
190
+
191
+ 对于 `git push --force origin main`:
192
+ - tokens = [`push`, `--force`, `origin`, `main`]
193
+ - args 匹配规则:只要**任意一个 token** 匹配**任意一个 glob 模式**,规则就生效
194
+
195
+ #### 3.2 能做什么
196
+
197
+ ```yaml
198
+ # ✅ 拦截所有带 --force 的命令(不管是不是 push)
199
+ - args: ["--force"]
200
+ level: deny
201
+ reason: "Force flag detected"
202
+
203
+ # ✅ 拦截所有涉及 main 分支的命令
204
+ - args: ["main"]
205
+ level: deny
206
+ reason: "Main branch target detected"
207
+
208
+ # ✅ 拦截所有 git push 命令
209
+ - command: [git]
210
+ args: ["push"]
211
+ level: confirm
212
+ reason: "Git push requires confirmation"
213
+ ```
214
+
215
+ #### 3.3 不能做什么
216
+
217
+ ```yaml
218
+ # ❌ 精确匹配 "git push --force origin main" 这个复合操作
219
+ # 因为 args 是 OR 逻辑,无法要求多个 token 同时存在
220
+
221
+ # ❌ 区分 "git push --force origin main" 和 "git checkout --force main"
222
+ # 两者都有 --force 和 main,无法区分
223
+
224
+ # ❌ 动态判断分支是"个人"还是"共享"
225
+ # YAML 是静态 pattern,没有 git 查询能力
226
+
227
+ # ❌ 匹配分支模式(如 release/*、feature/*)
228
+ # args glob 不支持路径段匹配(segments=false)
229
+ ```
230
+
231
+ #### 3.4 结论
232
+
233
+ | 能力 | 状态 | 说明 |
234
+ |------|------|------|
235
+ | 拦截 force push | ⚠️ 部分可行 | 只能匹配 `--force` token,无法限定是 push 操作 |
236
+ | 拦截特定分支 | ⚠️ 部分可行 | 只能硬编码分支名,无法判断共享属性 |
237
+ | 复合条件匹配 | ❌ 不可行 | args 是 OR 逻辑,无法要求多个 token 同时存在 |
238
+ | 运行时 git 查询 | ❌ 不可行 | YAML 是静态 pattern,无法调用 git 命令 |
239
+ | 分支模式匹配 | ⚠️ 部分可行 | 可用 `release*` 匹配,但无法匹配路径段 |
240
+
241
+ ### 3.5 要实现真正的分支级权限,需要
242
+
243
+ #### 方案 A: 新增 `branch` 维度
244
+
245
+ ```yaml
246
+ permissions:
247
+ deny:
248
+ - branch:
249
+ target: "main|master|develop|release/*"
250
+ shared: true # 动态判断是否共享
251
+ reason: "Force push to shared branch"
252
+ ```
253
+
254
+ **实现要求**:
255
+ - 运行时调用 `git branch -r --contains <branch>` 查询分支属性
256
+ - 查询 `git log --format='%ae' <branch>` 获取提交者信息
257
+ - 查询 `git config branch.<name>.remote` 获取远程仓库
258
+ - 缓存查询结果(避免重复调用)
259
+ - 添加超时机制(防止 git 命令卡住)
260
+
261
+ #### 方案 B: 改造 args 为 AND 逻辑
262
+
263
+ ```yaml
264
+ permissions:
265
+ deny:
266
+ - args_match: AND # 新语法:要求所有条件同时满足
267
+ - "push"
268
+ - "--force"
269
+ - "origin"
270
+ - "main|master|develop"
271
+ reason: "Force push to shared branch"
272
+ ```
273
+
274
+ **实现要求**:
275
+ - 修改 args 维度的匹配逻辑(从 OR 改为 AND)
276
+ - 添加新语法支持(`args_match: AND`)
277
+ - 保持向后兼容(旧的 `args` 保持 OR 逻辑)
278
+
279
+ #### 方案 C: 新增 `shared-branches` 配置
280
+
281
+ ```yaml
282
+ # rules.yml 顶部配置
283
+ shared_branches:
284
+ - main
285
+ - master
286
+ - develop
287
+ - "release/*"
288
+
289
+ permissions:
290
+ deny:
291
+ - command: [git]
292
+ args: ["push", "--force"]
293
+ branch:
294
+ in: shared_branches # 引用预定义列表
295
+ reason: "Force push to shared branch"
296
+ ```
297
+
298
+ **实现要求**:
299
+ - 新增 `shared_branches` 配置项
300
+ - 新增 `branch` 维度,支持 `in` 操作符引用列表
301
+ - 运行时解析分支名,匹配列表中的模式
302
+
303
+ ---
304
+
305
+ ## 四、建议的实施路径
306
+
307
+ ### 短期 (1-2 周)
308
+
309
+ 1. **Phase 1 UI 补全**: 集成 YAML 编辑器,暴露完整的规则编辑能力
310
+ 2. **文档更新**: 明确说明 YAML 规则的完整能力边界
311
+ 3. **临时方案**: 在文档中提供手动配置分支级权限的方法(硬编码分支名)
312
+
313
+ ### 中期 (1-2 月)
314
+
315
+ 1. **Phase 2 可视化编辑器**: 提供图形化规则创建界面
316
+ 2. **args AND 逻辑**: 改造 args 维度,支持复合条件匹配
317
+ 3. **规则测试功能**: 支持在 UI 中测试规则匹配结果
318
+
319
+ ### 长期 (3-6 月)
320
+
321
+ 1. **branch 维度**: 实现运行时 git 查询,支持动态分支判断
322
+ 2. **规则版本控制**: 支持规则变更历史和回滚
323
+ 3. **规则审计日志**: 记录规则匹配历史,支持审计
324
+
325
+ ---
326
+
327
+ ## 五、与 PerryLink dsh-auto-review 的对比总结
328
+
329
+ | 维度 | dsh-perm-gate | dsh-auto-review | 差距分析 |
330
+ |------|--------------|-----------------|----------|
331
+ | 规则格式 | YAML 静态规则 | cordis patch 配置 | 格式不同,无法直接兼容 |
332
+ | 匹配逻辑 | 模式匹配 (pattern) | AI 语义理解 | 各有优势:模式匹配快,语义理解准 |
333
+ | 分支权限 | 不支持动态判断 | 不支持(无分支维度) | 两者都需要新增能力 |
334
+ | UI 面板 | 简化接口,缺失 YAML 编辑器 | 完整配置面板 | 我们需要补全 UI |
335
+ | 配置入口 | settings.yaml | cordis.patch.yml | 入口不同,无法直接融合 |
336
+ | 执行速度 | < 10ms (静态匹配) | 3-60s (AI 审查) | 我们的速度优势明显 |
337
+
338
+ **核心结论**: 两者是互补关系,不是替代关系。perm-gate 的静态规则适合确定性拦截(速度优先),auto-review 的 AI 审查适合模糊场景(精度优先)。分支级权限是两者都缺失的能力,需要各自实现。
package/dsh.plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "dsh-perm-gate",
3
3
  "name": "dsh-perm-gate",
4
- "version": "2.1.2",
4
+ "version": "3.0.1",
5
5
  "description": {
6
6
  "en": "Deterministic-first, fail-closed permission gate for DeepSeek Harness 0.1.2+: a P0 hard-deny -> P1 precise session grant -> P2 static allow/deny rule chain -> P3 optional LLM classifier -> P4 ask pipeline over the tools/pre-execute waterfall, with a standalone CLI dry-run evaluator and an auditable decision log. It only vetoes with deny/ask and always delegates otherwise, and unknown or malformed rules fail loud instead of silently standing the gate down.",
7
7
  "zh": "面向 DeepSeek Harness 0.1.2+ 的确定性优先、失败即拒的权限闸门:在 tools/pre-execute 瀑布上串起 P0 硬拒绝 → P1 精确会话授权 → P2 静态 allow/deny 规则链 → P3 可选 LLM 语义分类 → P4 ask,并附带独立 CLI 干跑评估器与可审计的决策日志。只以 deny/ask 否决、其余一律放行,规则未知或非法时直接报错,绝不静默失效。"
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "license": "MIT",
15
15
  "engines": {
16
- "dsh": ">=0.1.2-alpha.1"
16
+ "dsh": ">=0.1.2-alpha.1 <0.2.0-0"
17
17
  },
18
18
  "components": {
19
19
  "host": "lib/index.js",
@@ -9,8 +9,23 @@ permissions:
9
9
  reason: no direct remote shell
10
10
  - paths: [.dsh/**]
11
11
  reason: protect harness metadata
12
+ # `command` matches the decomposed command WORD only (`git`), so the
13
+ # subcommand belongs in `args`. A multi-word entry such as `git pull` can
14
+ # never match — see docs/rules-format.md §4.2.
15
+ - command: [git]
16
+ args: [push]
17
+ branch:
18
+ target: [main, master, "release*"]
19
+ shared: true
20
+ reason: no push to a protected branch
12
21
  allow:
13
- - command: [git pull, pnpm, node, npm run]
22
+ - command: [pnpm, node]
23
+ reason: routine project development
24
+ - command: [git]
25
+ args: [pull]
26
+ reason: routine project development
27
+ - command: [npm]
28
+ args: [run]
14
29
  reason: routine project development
15
30
  - command: [curl, wget]
16
31
  args: ["https://*.example.com/*"]
@@ -1,3 +1,30 @@
1
+ /**
2
+ * Minimal face of the DSH settings scope the rules namespace is bound to
3
+ * (`scope.get()` / `scope.update(patch)`; the host merges a patch into the
4
+ * user layer, replacing arrays wholesale).
5
+ */
6
+ export interface SettingsRulesScope {
7
+ get(): unknown;
8
+ update(patch: object): Promise<unknown> | unknown;
9
+ }
10
+ /** The allow-list command patterns currently in the settings rules document (read-only). */
11
+ export declare function listAllowFromRulesDoc(doc: unknown): string[];
12
+ /**
13
+ * Append one command pattern as a new `allow` entry in the settings rules
14
+ * namespace. When the namespace is still unconfigured (bare defaults) and a
15
+ * file document is supplied, the namespace is seeded from it first — the
16
+ * implicit migration — so the first settings write cannot shadow file-defined
17
+ * deny/ask rules. Returns false when the scope rejects the write.
18
+ */
19
+ export declare function appendAllowToSettings(scope: SettingsRulesScope, pattern: string, reason?: string, seed?: unknown): Promise<boolean>;
20
+ /**
21
+ * Replace the settings rules namespace's `allow` whitelist with exactly the
22
+ * given command patterns (one allow entry per pattern); empty clears it.
23
+ * Other sections (deny / ask / defaultAction) are preserved — seeded from the
24
+ * file document on the namespace's first write. Returns false when the scope
25
+ * rejects the write.
26
+ */
27
+ export declare function replaceAllowInSettings(scope: SettingsRulesScope, patterns: readonly string[], reason?: string, seed?: unknown): Promise<boolean>;
1
28
  /**
2
29
  * Append one command pattern as a new `allow` entry in the rules file.
3
30
  * Returns false (leaving the file untouched) on any I/O or shape error.
package/lib/allowlist.js CHANGED
@@ -2,11 +2,113 @@
2
2
  * Durable allowlist (whitelist) writes for dsh-perm-gate.
3
3
  *
4
4
  * The "allow everywhere" approval action appends a matched command pattern to
5
- * the rules file's `allow` list and reloads, so a human-approved command is
5
+ * the rules' `allow` list and reloads, so a human-approved command is
6
6
  * persistently whitelisted across sessions — not just granted once.
7
+ *
8
+ * Two storage backs, per the dual-source migration:
9
+ * - **settings** (`appendAllowToSettings` / `replaceAllowInSettings`) — the
10
+ * `dsh-perm-gate-rules` namespace; the primary write path once the settings
11
+ * service is available.
12
+ * - **rules file** (`appendAllowCommand` / `replaceAllowCommands`) — the legacy
13
+ * path, kept as the fallback when no settings scope is wired (and as the
14
+ * migration seed: the first settings write carries the file document along so
15
+ * file-defined deny/ask rules are not shadowed).
7
16
  */
8
17
  import { readFileSync, writeFileSync } from 'node:fs';
9
18
  import { parse, stringify } from 'yaml';
19
+ import { isRulesConfigured } from './config.js';
20
+ function isRecord(v) {
21
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
22
+ }
23
+ /** Accept both the bare rules form and the file's `permissions:`-wrapped form. */
24
+ function unwrapPermissions(seed) {
25
+ if (!isRecord(seed))
26
+ return {};
27
+ return isRecord(seed['permissions']) ? seed['permissions'] : seed;
28
+ }
29
+ /** The file document a first write should migrate into the namespace, or bare defaults. */
30
+ function seedOrDefaults(seed) {
31
+ const root = unwrapPermissions(seed);
32
+ return {
33
+ defaultAction: typeof root['defaultAction'] === 'string' ? root['defaultAction'] : 'ask',
34
+ deny: Array.isArray(root['deny']) ? root['deny'] : [],
35
+ allow: Array.isArray(root['allow']) ? root['allow'] : [],
36
+ ask: Array.isArray(root['ask']) ? root['ask'] : [],
37
+ };
38
+ }
39
+ function allowEntriesOf(section) {
40
+ const allow = section['allow'];
41
+ return Array.isArray(allow) ? allow : [];
42
+ }
43
+ function commandEntries(patterns, reason) {
44
+ return patterns.map((pattern) => ({ command: [pattern], reason }));
45
+ }
46
+ /** The allow-list command patterns currently in the settings rules document (read-only). */
47
+ export function listAllowFromRulesDoc(doc) {
48
+ const section = unwrapPermissions(doc);
49
+ const out = [];
50
+ for (const entry of allowEntriesOf(section)) {
51
+ if (!isRecord(entry))
52
+ continue;
53
+ const command = entry['command'];
54
+ if (typeof command === 'string') {
55
+ out.push(command);
56
+ continue;
57
+ }
58
+ if (!Array.isArray(command))
59
+ continue;
60
+ for (const c of command)
61
+ if (typeof c === 'string')
62
+ out.push(c);
63
+ }
64
+ return out;
65
+ }
66
+ /**
67
+ * Append one command pattern as a new `allow` entry in the settings rules
68
+ * namespace. When the namespace is still unconfigured (bare defaults) and a
69
+ * file document is supplied, the namespace is seeded from it first — the
70
+ * implicit migration — so the first settings write cannot shadow file-defined
71
+ * deny/ask rules. Returns false when the scope rejects the write.
72
+ */
73
+ export async function appendAllowToSettings(scope, pattern, reason = 'permissive allow-everywhere', seed) {
74
+ try {
75
+ const current = scope.get();
76
+ const section = isRulesConfigured(current) && isRecord(current)
77
+ ? { ...current }
78
+ : seedOrDefaults(seed);
79
+ const allow = allowEntriesOf(section);
80
+ allow.push({ command: [pattern], reason });
81
+ section['allow'] = allow;
82
+ await scope.update(section);
83
+ return true;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ }
89
+ /**
90
+ * Replace the settings rules namespace's `allow` whitelist with exactly the
91
+ * given command patterns (one allow entry per pattern); empty clears it.
92
+ * Other sections (deny / ask / defaultAction) are preserved — seeded from the
93
+ * file document on the namespace's first write. Returns false when the scope
94
+ * rejects the write.
95
+ */
96
+ export async function replaceAllowInSettings(scope, patterns, reason = 'permissive allowlist', seed) {
97
+ try {
98
+ if (patterns === undefined)
99
+ return false;
100
+ const current = scope.get();
101
+ const section = isRulesConfigured(current) && isRecord(current)
102
+ ? { ...current }
103
+ : seedOrDefaults(seed);
104
+ section['allow'] = commandEntries(patterns, reason);
105
+ await scope.update(section);
106
+ return true;
107
+ }
108
+ catch {
109
+ return false;
110
+ }
111
+ }
10
112
  /**
11
113
  * Append one command pattern as a new `allow` entry in the rules file.
12
114
  * Returns false (leaving the file untouched) on any I/O or shape error.
package/lib/cli.js CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { parseArgs } from 'node:util';
12
12
  import { resolve } from 'node:path';
13
- import { PermGateRuntime } from './runtime.js';
13
+ import { createDryRunRuntime, runDryRun } from './dry-run.js';
14
14
  function main() {
15
15
  const { values } = parseArgs({
16
16
  options: {
@@ -23,11 +23,7 @@ function main() {
23
23
  },
24
24
  });
25
25
  const rulesFile = values.rules ? resolve(values.rules) : undefined;
26
- const runtime = new PermGateRuntime({
27
- rulesFile,
28
- caseInsensitivePaths: true,
29
- permissive: values.permissive || undefined,
30
- });
26
+ const runtime = createDryRunRuntime({ rulesFile, permissive: values.permissive });
31
27
  if (values.list) {
32
28
  process.stdout.write(JSON.stringify({
33
29
  rulesFile,
@@ -53,15 +49,16 @@ function main() {
53
49
  process.exitCode = 2;
54
50
  return;
55
51
  }
56
- const decision = runtime.decideExecution({
57
- name: tool,
58
- arguments: args,
59
- cwd: values.cwd ? resolve(values.cwd) : process.cwd(),
60
- });
52
+ // `hostView` reproduces this command's historic output exactly: it reports the
53
+ // host-facing decision path (whose `allow` is a bare passthrough with no
54
+ // reason) and its audit count. The panel reports the pure policy verdict
55
+ // instead, because a session-less caller must not inherit the session layers.
56
+ const result = runDryRun({ tool, args, rulesFile, cwd: values.cwd, hostView: true }, runtime);
57
+ const host = result.host;
61
58
  process.stdout.write(JSON.stringify({
62
- decision: decision === undefined ? 'allow' : decision.kind,
63
- reason: decision?.reason ?? '(default/passthrough)',
64
- audited: runtime.auditEntries.length,
59
+ decision: host?.verdict ?? result.verdict,
60
+ reason: host?.reason ?? result.reason,
61
+ audited: host?.audited ?? 0,
65
62
  }, null, 2) + '\n');
66
63
  }
67
64
  main();