dsh-perm-gate 5.1.2 → 5.2.0

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.
@@ -0,0 +1,44 @@
1
+ # Third-Party Notices
2
+
3
+ ## cc-safety-net — source of the default deny/ask rule table
4
+
5
+ The preset rule catalog in `src/preset-rules.ts` (`PRESET_RULES_YAML`, the
6
+ `[deny-rule:*]` entries seeded into the live rules document on first
7
+ activation) is a port of the rule catalog from **cc-safety-net**:
8
+
9
+ - Project: <https://github.com/kenryu42/cc-safety-net>
10
+ - Files ported: `src/core/rules/destructive.ts` and `src/core/rules/secret.ts`
11
+ - Form of the port: the rules were **re-expressed** in this plugin's own
12
+ `permissions` YAML vocabulary (`command` / `argv` / `tools` dimensions, see
13
+ `src/rule.ts`); pattern shapes, tiering and verdicts were re-decided for this
14
+ gate's fail-toward-`ask` philosophy rather than copied verbatim.
15
+ - Deviations and coverage gaps are recorded in
16
+ `.agents/plans/compat-cc-safety-net-deny-table/rewrite-target.md`.
17
+
18
+ cc-safety-net is distributed under the MIT License, reproduced in full below;
19
+ its copyright notice is retained here and in the header of
20
+ `src/preset-rules.ts`.
21
+
22
+ ```
23
+ MIT License
24
+
25
+ Copyright (c) 2026 J Liew
26
+
27
+ Permission is hereby granted, free of charge, to any person obtaining a copy
28
+ of this software and associated documentation files (the "Software"), to deal
29
+ in the Software without restriction, including without limitation the rights
30
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
31
+ copies of the Software, and to permit persons to whom the Software is
32
+ furnished to do so, subject to the following conditions:
33
+
34
+ The above copyright notice and this permission notice shall be included in all
35
+ copies or substantial portions of the Software.
36
+
37
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
38
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
39
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
40
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
41
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
42
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
43
+ SOFTWARE.
44
+ ```
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": "5.1.2",
4
+ "version": "5.2.0",
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 否决、其余一律放行,规则未知或非法时直接报错,绝不静默失效。"
package/lib/config.d.ts CHANGED
@@ -95,6 +95,13 @@ export interface PermGateConfig {
95
95
  * (possibly empty) replaces it. Editable from the settings card as a list.
96
96
  */
97
97
  readonly denyKeywords?: string[];
98
+ /**
99
+ * One-shot marker for the preset deny-table seed (`preset-rules.ts`, ported
100
+ * from cc-safety-net): set once the `[deny-rule:*]` entries have been merged
101
+ * into the live rules source. A full revocation (deleting every preset entry)
102
+ * persists — the seed never re-adds them.
103
+ */
104
+ readonly presetRulesSeeded?: boolean;
98
105
  /**
99
106
  * Extra tool names classified as read-only/internal and therefore auto-allowed.
100
107
  *
@@ -332,126 +339,128 @@ export declare function readRulesFromSettings(scope: {
332
339
  export declare const Config: z<Schemastery.ObjectS<NoInfer<{
333
340
  rulesFile: z<string, string, "plain">;
334
341
  dshHome: z<string, string, "plain">;
335
- defaultAction: z<"allow" | "ask" | "deny", "allow" | "ask" | "deny", "volatile-defined">;
336
- caseInsensitivePaths: z<boolean, boolean, "volatile-defined">;
337
- classifierEnabled: z<boolean, boolean, "volatile-defined">;
338
- classifierEndpoint: z<string, string, "volatile">;
339
- classifierModel: z<string, string, "volatile-defined">;
340
- classifierApiKey: z<string, string, "volatile">;
341
- riskTimeoutMs: z<number, number, "volatile-defined">;
342
- riskLearning: z<boolean, boolean, "volatile-defined">;
343
- riskSediment: z<boolean, boolean, "volatile-defined">;
344
- classifierSource: z<"host" | "custom", "host" | "custom", "volatile-defined">;
345
- classifierProvider: z<string, string, "volatile">;
346
- riskThreshold: z<number, number, "volatile-defined">;
342
+ defaultAction: z<"allow" | "ask" | "deny", "allow" | "ask" | "deny", "defined">;
343
+ caseInsensitivePaths: z<boolean, boolean, "defined">;
344
+ classifierEnabled: z<boolean, boolean, "defined">;
345
+ classifierEndpoint: z<string, string, "plain">;
346
+ classifierModel: z<string, string, "defined">;
347
+ classifierApiKey: z<string, string, "plain">;
348
+ riskTimeoutMs: z<number, number, "defined">;
349
+ riskLearning: z<boolean, boolean, "defined">;
350
+ riskSediment: z<boolean, boolean, "defined">;
351
+ classifierSource: z<"host" | "custom", "host" | "custom", "defined">;
352
+ classifierProvider: z<string, string, "plain">;
353
+ riskThreshold: z<number, number, "defined">;
347
354
  learningFile: z<string, string, "plain">;
348
355
  eventsFile: z<string, string, "plain">;
349
- grantTtlMs: z<number, number, "volatile-defined">;
350
- grantMaxUses: z<number, number, "volatile-defined">;
351
- permissive: z<boolean, boolean, "volatile-defined">;
356
+ grantTtlMs: z<number, number, "defined">;
357
+ grantMaxUses: z<number, number, "defined">;
358
+ permissive: z<boolean, boolean, "defined">;
352
359
  gatePresets: z<string[], string[], "plain">;
353
- permissiveStrategies: z<NoInfer<Schemastery.ObjectS<NoInfer<{
360
+ permissiveStrategies: z<Schemastery.ObjectS<NoInfer<{
354
361
  trustAutoAllow: z<boolean, boolean, "defined">;
355
362
  alwaysConfirm: z<boolean, boolean, "defined">;
356
363
  llmAssist: z<boolean, boolean, "defined">;
357
364
  trustEscalation: z<boolean, boolean, "defined">;
358
- }>>>, NoInfer<Schemastery.ObjectT<NoInfer<{
365
+ }>>, Schemastery.ObjectT<NoInfer<{
359
366
  trustAutoAllow: z<boolean, boolean, "defined">;
360
367
  alwaysConfirm: z<boolean, boolean, "defined">;
361
368
  llmAssist: z<boolean, boolean, "defined">;
362
369
  trustEscalation: z<boolean, boolean, "defined">;
363
- }>>>, "volatile">;
364
- allowlist: z<NoInfer<string[]>, NoInfer<string[]>, "volatile">;
365
- denyKeywords: z<NoInfer<string[]>, NoInfer<string[]>, "volatile">;
366
- autoAllowTools: z<NoInfer<string[]>, NoInfer<string[]>, "volatile">;
367
- sessionSweep: z<boolean, boolean, "volatile-defined">;
370
+ }>>, "plain">;
371
+ allowlist: z<string[], string[], "plain">;
372
+ denyKeywords: z<string[], string[], "plain">;
373
+ presetRulesSeeded: z<boolean, boolean, "defined">;
374
+ autoAllowTools: z<string[], string[], "plain">;
375
+ sessionSweep: z<boolean, boolean, "defined">;
368
376
  workspaceStoreFile: z<string, string, "plain">;
369
- searchUp: z<boolean, boolean, "volatile-defined">;
377
+ searchUp: z<boolean, boolean, "defined">;
370
378
  fallbackPath: z<string, string, "plain">;
371
- badFilePolicy: z<"fail" | "warn", "fail" | "warn", "volatile-defined">;
372
- maxChainLength: z<number, number, "volatile-defined">;
373
- networkEnabled: z<boolean, boolean, "volatile-defined">;
374
- networkMode: z<"deny-all" | "whitelist" | "allow-all", "deny-all" | "whitelist" | "allow-all", "volatile-defined">;
375
- networkUnlisted: z<"ask" | "deny", "ask" | "deny", "volatile-defined">;
376
- networkUnattributed: z<"allow" | "deny", "allow" | "deny", "volatile-defined">;
377
- networkLoopback: z<"allow" | "policy", "allow" | "policy", "volatile-defined">;
378
- networkBind: z<string, string, "volatile-defined">;
379
- networkPort: z<number, number, "volatile-defined">;
380
- networkNoProxy: z<"clear" | "preserve", "clear" | "preserve", "volatile-defined">;
381
- networkInjectEnv: z<boolean, boolean, "volatile-defined">;
382
- networkAskTimeoutMs: z<number, number, "volatile-defined">;
383
- networkGrantTtlMs: z<number, number, "volatile-defined">;
384
- sandboxEnabled: z<boolean, boolean, "volatile-defined">;
385
- sandboxMode: z<"read-only" | "workspace-write", "read-only" | "workspace-write", "volatile-defined">;
386
- sandboxProxy: z<string, string, "volatile-defined">;
387
- sandboxLoopback: z<"exempt" | "off", "exempt" | "off", "volatile-defined">;
388
- rules: z<NoInfer<RulesConfig>, NoInfer<RulesConfig>, "volatile">;
379
+ badFilePolicy: z<"fail" | "warn", "fail" | "warn", "defined">;
380
+ maxChainLength: z<number, number, "defined">;
381
+ networkEnabled: z<boolean, boolean, "defined">;
382
+ networkMode: z<"deny-all" | "whitelist" | "allow-all", "deny-all" | "whitelist" | "allow-all", "defined">;
383
+ networkUnlisted: z<"ask" | "deny", "ask" | "deny", "defined">;
384
+ networkUnattributed: z<"allow" | "deny", "allow" | "deny", "defined">;
385
+ networkLoopback: z<"allow" | "policy", "allow" | "policy", "defined">;
386
+ networkBind: z<string, string, "defined">;
387
+ networkPort: z<number, number, "defined">;
388
+ networkNoProxy: z<"clear" | "preserve", "clear" | "preserve", "defined">;
389
+ networkInjectEnv: z<boolean, boolean, "defined">;
390
+ networkAskTimeoutMs: z<number, number, "defined">;
391
+ networkGrantTtlMs: z<number, number, "defined">;
392
+ sandboxEnabled: z<boolean, boolean, "defined">;
393
+ sandboxMode: z<"read-only" | "workspace-write", "read-only" | "workspace-write", "defined">;
394
+ sandboxProxy: z<string, string, "defined">;
395
+ sandboxLoopback: z<"exempt" | "off", "exempt" | "off", "defined">;
396
+ rules: z<RulesConfig>;
389
397
  watch: z<boolean, boolean, "defined">;
390
398
  watchDebounceMs: z<number, number, "defined">;
391
399
  }>>, Schemastery.ObjectT<NoInfer<{
392
400
  rulesFile: z<string, string, "plain">;
393
401
  dshHome: z<string, string, "plain">;
394
- defaultAction: z<"allow" | "ask" | "deny", "allow" | "ask" | "deny", "volatile-defined">;
395
- caseInsensitivePaths: z<boolean, boolean, "volatile-defined">;
396
- classifierEnabled: z<boolean, boolean, "volatile-defined">;
397
- classifierEndpoint: z<string, string, "volatile">;
398
- classifierModel: z<string, string, "volatile-defined">;
399
- classifierApiKey: z<string, string, "volatile">;
400
- riskTimeoutMs: z<number, number, "volatile-defined">;
401
- riskLearning: z<boolean, boolean, "volatile-defined">;
402
- riskSediment: z<boolean, boolean, "volatile-defined">;
403
- classifierSource: z<"host" | "custom", "host" | "custom", "volatile-defined">;
404
- classifierProvider: z<string, string, "volatile">;
405
- riskThreshold: z<number, number, "volatile-defined">;
402
+ defaultAction: z<"allow" | "ask" | "deny", "allow" | "ask" | "deny", "defined">;
403
+ caseInsensitivePaths: z<boolean, boolean, "defined">;
404
+ classifierEnabled: z<boolean, boolean, "defined">;
405
+ classifierEndpoint: z<string, string, "plain">;
406
+ classifierModel: z<string, string, "defined">;
407
+ classifierApiKey: z<string, string, "plain">;
408
+ riskTimeoutMs: z<number, number, "defined">;
409
+ riskLearning: z<boolean, boolean, "defined">;
410
+ riskSediment: z<boolean, boolean, "defined">;
411
+ classifierSource: z<"host" | "custom", "host" | "custom", "defined">;
412
+ classifierProvider: z<string, string, "plain">;
413
+ riskThreshold: z<number, number, "defined">;
406
414
  learningFile: z<string, string, "plain">;
407
415
  eventsFile: z<string, string, "plain">;
408
- grantTtlMs: z<number, number, "volatile-defined">;
409
- grantMaxUses: z<number, number, "volatile-defined">;
410
- permissive: z<boolean, boolean, "volatile-defined">;
416
+ grantTtlMs: z<number, number, "defined">;
417
+ grantMaxUses: z<number, number, "defined">;
418
+ permissive: z<boolean, boolean, "defined">;
411
419
  gatePresets: z<string[], string[], "plain">;
412
- permissiveStrategies: z<NoInfer<Schemastery.ObjectS<NoInfer<{
420
+ permissiveStrategies: z<Schemastery.ObjectS<NoInfer<{
413
421
  trustAutoAllow: z<boolean, boolean, "defined">;
414
422
  alwaysConfirm: z<boolean, boolean, "defined">;
415
423
  llmAssist: z<boolean, boolean, "defined">;
416
424
  trustEscalation: z<boolean, boolean, "defined">;
417
- }>>>, NoInfer<Schemastery.ObjectT<NoInfer<{
425
+ }>>, Schemastery.ObjectT<NoInfer<{
418
426
  trustAutoAllow: z<boolean, boolean, "defined">;
419
427
  alwaysConfirm: z<boolean, boolean, "defined">;
420
428
  llmAssist: z<boolean, boolean, "defined">;
421
429
  trustEscalation: z<boolean, boolean, "defined">;
422
- }>>>, "volatile">;
423
- allowlist: z<NoInfer<string[]>, NoInfer<string[]>, "volatile">;
424
- denyKeywords: z<NoInfer<string[]>, NoInfer<string[]>, "volatile">;
425
- autoAllowTools: z<NoInfer<string[]>, NoInfer<string[]>, "volatile">;
426
- sessionSweep: z<boolean, boolean, "volatile-defined">;
430
+ }>>, "plain">;
431
+ allowlist: z<string[], string[], "plain">;
432
+ denyKeywords: z<string[], string[], "plain">;
433
+ presetRulesSeeded: z<boolean, boolean, "defined">;
434
+ autoAllowTools: z<string[], string[], "plain">;
435
+ sessionSweep: z<boolean, boolean, "defined">;
427
436
  workspaceStoreFile: z<string, string, "plain">;
428
- searchUp: z<boolean, boolean, "volatile-defined">;
437
+ searchUp: z<boolean, boolean, "defined">;
429
438
  fallbackPath: z<string, string, "plain">;
430
- badFilePolicy: z<"fail" | "warn", "fail" | "warn", "volatile-defined">;
431
- maxChainLength: z<number, number, "volatile-defined">;
432
- networkEnabled: z<boolean, boolean, "volatile-defined">;
433
- networkMode: z<"deny-all" | "whitelist" | "allow-all", "deny-all" | "whitelist" | "allow-all", "volatile-defined">;
434
- networkUnlisted: z<"ask" | "deny", "ask" | "deny", "volatile-defined">;
435
- networkUnattributed: z<"allow" | "deny", "allow" | "deny", "volatile-defined">;
436
- networkLoopback: z<"allow" | "policy", "allow" | "policy", "volatile-defined">;
437
- networkBind: z<string, string, "volatile-defined">;
438
- networkPort: z<number, number, "volatile-defined">;
439
- networkNoProxy: z<"clear" | "preserve", "clear" | "preserve", "volatile-defined">;
440
- networkInjectEnv: z<boolean, boolean, "volatile-defined">;
441
- networkAskTimeoutMs: z<number, number, "volatile-defined">;
442
- networkGrantTtlMs: z<number, number, "volatile-defined">;
443
- sandboxEnabled: z<boolean, boolean, "volatile-defined">;
444
- sandboxMode: z<"read-only" | "workspace-write", "read-only" | "workspace-write", "volatile-defined">;
445
- sandboxProxy: z<string, string, "volatile-defined">;
446
- sandboxLoopback: z<"exempt" | "off", "exempt" | "off", "volatile-defined">;
447
- rules: z<NoInfer<RulesConfig>, NoInfer<RulesConfig>, "volatile">;
439
+ badFilePolicy: z<"fail" | "warn", "fail" | "warn", "defined">;
440
+ maxChainLength: z<number, number, "defined">;
441
+ networkEnabled: z<boolean, boolean, "defined">;
442
+ networkMode: z<"deny-all" | "whitelist" | "allow-all", "deny-all" | "whitelist" | "allow-all", "defined">;
443
+ networkUnlisted: z<"ask" | "deny", "ask" | "deny", "defined">;
444
+ networkUnattributed: z<"allow" | "deny", "allow" | "deny", "defined">;
445
+ networkLoopback: z<"allow" | "policy", "allow" | "policy", "defined">;
446
+ networkBind: z<string, string, "defined">;
447
+ networkPort: z<number, number, "defined">;
448
+ networkNoProxy: z<"clear" | "preserve", "clear" | "preserve", "defined">;
449
+ networkInjectEnv: z<boolean, boolean, "defined">;
450
+ networkAskTimeoutMs: z<number, number, "defined">;
451
+ networkGrantTtlMs: z<number, number, "defined">;
452
+ sandboxEnabled: z<boolean, boolean, "defined">;
453
+ sandboxMode: z<"read-only" | "workspace-write", "read-only" | "workspace-write", "defined">;
454
+ sandboxProxy: z<string, string, "defined">;
455
+ sandboxLoopback: z<"exempt" | "off", "exempt" | "off", "defined">;
456
+ rules: z<RulesConfig>;
448
457
  watch: z<boolean, boolean, "defined">;
449
458
  watchDebounceMs: z<number, number, "defined">;
450
459
  }>>, "plain">;
451
460
  /** Live reference the 0.1.7 loader hands `apply` for `.volatile()` config fields. */
452
461
  export type VolatileRef<T> = Volatile<T>;
453
462
  /** The config fields marked `.volatile()` — live refs inside `apply`'s config. */
454
- export declare const VOLATILE_CONFIG_KEYS: readonly ["defaultAction", "caseInsensitivePaths", "classifierEnabled", "classifierEndpoint", "classifierModel", "classifierApiKey", "riskTimeoutMs", "riskLearning", "riskSediment", "classifierSource", "classifierProvider", "riskThreshold", "grantTtlMs", "grantMaxUses", "permissive", "permissiveStrategies", "allowlist", "denyKeywords", "autoAllowTools", "sessionSweep", "searchUp", "badFilePolicy", "maxChainLength", "networkEnabled", "networkMode", "networkUnlisted", "networkUnattributed", "networkLoopback", "networkBind", "networkPort", "networkNoProxy", "networkInjectEnv", "networkAskTimeoutMs", "networkGrantTtlMs", "rules", "sandboxEnabled", "sandboxMode", "sandboxProxy", "sandboxLoopback"];
463
+ export declare const VOLATILE_CONFIG_KEYS: readonly ["defaultAction", "caseInsensitivePaths", "classifierEnabled", "classifierEndpoint", "classifierModel", "classifierApiKey", "riskTimeoutMs", "riskLearning", "riskSediment", "classifierSource", "classifierProvider", "riskThreshold", "grantTtlMs", "grantMaxUses", "permissive", "permissiveStrategies", "allowlist", "denyKeywords", "presetRulesSeeded", "autoAllowTools", "sessionSweep", "searchUp", "badFilePolicy", "maxChainLength", "networkEnabled", "networkMode", "networkUnlisted", "networkUnattributed", "networkLoopback", "networkBind", "networkPort", "networkNoProxy", "networkInjectEnv", "networkAskTimeoutMs", "networkGrantTtlMs", "rules", "sandboxEnabled", "sandboxMode", "sandboxProxy", "sandboxLoopback"];
455
464
  /** Resolve one possibly-volatile field: a live ref on 0.1.7+, a plain value otherwise. */
456
465
  export declare function readVolatileValue<T>(value: T | Volatile<T> | undefined): VolatileSnapshot<T> | undefined;
457
466
  /** Shallow-resolve every volatile field into a plain snapshot (one per read). */
package/lib/config.js CHANGED
@@ -150,64 +150,88 @@ export function readRulesFromSettings(scope) {
150
150
  const value = scope?.get();
151
151
  return isRulesConfigured(value) ? value : undefined;
152
152
  }
153
+ /**
154
+ * Feature guard for `Schema…volatile()`.
155
+ *
156
+ * `.volatile()` is not guaranteed to exist on the schemastery copy this plugin
157
+ * resolves at runtime. Every node below used to chain it unguarded **inside the
158
+ * module body**, so a missing method throws while `Config` is being built: the
159
+ * plugin fails to load at all (`engines.dsh` declares support for all 15 host
160
+ * lines, so that is a hard load-failure, not a degradation).
161
+ *
162
+ * Probing per node keeps the plain schema node instead. The read side already
163
+ * tolerates that — `readVolatileValue` / `resolveVolatileConfig` treat a value
164
+ * without `get()` as the frozen snapshot — so every switch still works, and only
165
+ * hot reload of a changed value is lost (it then needs a restart).
166
+ *
167
+ * Do **not** “fix” this by adding `volatile` to schemastery's prototype: the
168
+ * profile uses a hoisted flat layout, so that single copy is shared by every
169
+ * plugin in the profile (14 of them declare schemastery themselves) — a
170
+ * prototype write here would silently change their behaviour too.
171
+ */
172
+ function vol(node) {
173
+ const maybe = node;
174
+ return typeof maybe.volatile === 'function' ? maybe.volatile() : node;
175
+ }
153
176
  export const Config = z.object({
154
177
  rulesFile: z.string(),
155
178
  dshHome: z.string(),
156
- defaultAction: z.union(['allow', 'ask', 'deny']).default('ask').volatile(),
157
- caseInsensitivePaths: z.boolean().default(true).volatile(),
158
- classifierEnabled: z.boolean().default(false).volatile(),
159
- classifierEndpoint: z.string().volatile(),
160
- classifierModel: z.string().default('deepseek-chat').volatile(),
161
- classifierApiKey: z.string().volatile(),
162
- riskTimeoutMs: z.number().min(1000).default(20_000).volatile(),
163
- riskLearning: z.boolean().default(true).volatile(),
164
- riskSediment: z.boolean().default(true).volatile(),
165
- classifierSource: z.union(['custom', 'host']).default('custom').volatile(),
166
- classifierProvider: z.string().volatile(),
167
- riskThreshold: z.number().min(1).max(10).default(1).volatile(),
179
+ defaultAction: vol(z.union(['allow', 'ask', 'deny']).default('ask')),
180
+ caseInsensitivePaths: vol(z.boolean().default(true)),
181
+ classifierEnabled: vol(z.boolean().default(false)),
182
+ classifierEndpoint: vol(z.string()),
183
+ classifierModel: vol(z.string().default('deepseek-chat')),
184
+ classifierApiKey: vol(z.string()),
185
+ riskTimeoutMs: vol(z.number().min(1000).default(20_000)),
186
+ riskLearning: vol(z.boolean().default(true)),
187
+ riskSediment: vol(z.boolean().default(true)),
188
+ classifierSource: vol(z.union(['custom', 'host']).default('custom')),
189
+ classifierProvider: vol(z.string()),
190
+ riskThreshold: vol(z.number().min(1).max(10).default(1)),
168
191
  learningFile: z.string(),
169
192
  eventsFile: z.string(),
170
- grantTtlMs: z.number().min(1).default(5 * 60_000).volatile(),
171
- grantMaxUses: z.number().min(1).default(1).volatile(),
172
- permissive: z.boolean().default(false).volatile(),
193
+ grantTtlMs: vol(z.number().min(1).default(5 * 60_000)),
194
+ grantMaxUses: vol(z.number().min(1).default(1)),
195
+ permissive: vol(z.boolean().default(false)),
173
196
  gatePresets: z.array(z.string()),
174
- permissiveStrategies: z.object({
197
+ permissiveStrategies: vol(z.object({
175
198
  trustAutoAllow: z.boolean().default(true),
176
199
  alwaysConfirm: z.boolean().default(false),
177
200
  llmAssist: z.boolean().default(false),
178
201
  trustEscalation: z.boolean().default(true),
179
- }).volatile(),
180
- allowlist: z.array(z.string()).volatile(),
181
- denyKeywords: z.array(z.string()).volatile(),
182
- autoAllowTools: z.array(z.string()).volatile(),
183
- sessionSweep: z.boolean().default(true).volatile(),
202
+ })),
203
+ allowlist: vol(z.array(z.string())),
204
+ denyKeywords: vol(z.array(z.string())),
205
+ presetRulesSeeded: vol(z.boolean().default(false)),
206
+ autoAllowTools: vol(z.array(z.string())),
207
+ sessionSweep: vol(z.boolean().default(true)),
184
208
  workspaceStoreFile: z.string(),
185
209
  // Rule chain (T1.9)
186
- searchUp: z.boolean().default(false).volatile(),
210
+ searchUp: vol(z.boolean().default(false)),
187
211
  fallbackPath: z.string(),
188
- badFilePolicy: z.union(['fail', 'warn']).default('fail').volatile(),
189
- maxChainLength: z.number().min(1).max(50).default(10).volatile(),
212
+ badFilePolicy: vol(z.union(['fail', 'warn']).default('fail')),
213
+ maxChainLength: vol(z.number().min(1).max(50).default(10)),
190
214
  // Network (Phase 2)
191
- networkEnabled: z.boolean().default(false).volatile(),
192
- networkMode: z.union(['deny-all', 'whitelist', 'allow-all']).default('whitelist').volatile(),
193
- networkUnlisted: z.union(['ask', 'deny']).default('ask').volatile(),
194
- networkUnattributed: z.union(['allow', 'deny']).default('allow').volatile(),
195
- networkLoopback: z.union(['allow', 'policy']).default('allow').volatile(),
196
- networkBind: z.string().default('127.0.0.1').volatile(),
197
- networkPort: z.number().min(0).max(65535).default(0).volatile(),
198
- networkNoProxy: z.union(['clear', 'preserve']).default('clear').volatile(),
199
- networkInjectEnv: z.boolean().default(true).volatile(),
200
- networkAskTimeoutMs: z.number().min(1000).max(600_000).default(120_000).volatile(),
201
- networkGrantTtlMs: z.number().min(0).max(24 * 60 * 60_000).default(30 * 60_000).volatile(),
215
+ networkEnabled: vol(z.boolean().default(false)),
216
+ networkMode: vol(z.union(['deny-all', 'whitelist', 'allow-all']).default('whitelist')),
217
+ networkUnlisted: vol(z.union(['ask', 'deny']).default('ask')),
218
+ networkUnattributed: vol(z.union(['allow', 'deny']).default('allow')),
219
+ networkLoopback: vol(z.union(['allow', 'policy']).default('allow')),
220
+ networkBind: vol(z.string().default('127.0.0.1')),
221
+ networkPort: vol(z.number().min(0).max(65535).default(0)),
222
+ networkNoProxy: vol(z.union(['clear', 'preserve']).default('clear')),
223
+ networkInjectEnv: vol(z.boolean().default(true)),
224
+ networkAskTimeoutMs: vol(z.number().min(1000).max(600_000).default(120_000)),
225
+ networkGrantTtlMs: vol(z.number().min(0).max(24 * 60 * 60_000).default(30 * 60_000)),
202
226
  // AppContainer sandbox (Windows; goal 2)
203
- sandboxEnabled: z.boolean().default(false).volatile(),
204
- sandboxMode: z.union(['read-only', 'workspace-write']).default('workspace-write').volatile(),
205
- sandboxProxy: z.string().default('').volatile(),
206
- sandboxLoopback: z.union(['exempt', 'off']).default('exempt').volatile(),
227
+ sandboxEnabled: vol(z.boolean().default(false)),
228
+ sandboxMode: vol(z.union(['read-only', 'workspace-write']).default('workspace-write')),
229
+ sandboxProxy: vol(z.string().default('')),
230
+ sandboxLoopback: vol(z.union(['exempt', 'off']).default('exempt')),
207
231
  // 0.1.7: the rules document lives on this entry as a volatile whole-object
208
232
  // field (a second settings namespace is no longer projectable). Unconfigured
209
233
  // (bare defaults) -> the rules file remains the source.
210
- rules: RulesSchema.volatile(),
234
+ rules: vol(RulesSchema),
211
235
  // Hot reload (Phase 3)
212
236
  watch: z.boolean().default(false),
213
237
  watchDebounceMs: z.number().min(50).max(5000).default(300),
@@ -217,7 +241,7 @@ export const VOLATILE_CONFIG_KEYS = [
217
241
  'defaultAction', 'caseInsensitivePaths', 'classifierEnabled', 'classifierEndpoint',
218
242
  'classifierModel', 'classifierApiKey', 'riskTimeoutMs', 'riskLearning', 'riskSediment',
219
243
  'classifierSource', 'classifierProvider', 'riskThreshold', 'grantTtlMs', 'grantMaxUses',
220
- 'permissive', 'permissiveStrategies', 'allowlist', 'denyKeywords', 'autoAllowTools',
244
+ 'permissive', 'permissiveStrategies', 'allowlist', 'denyKeywords', 'presetRulesSeeded', 'autoAllowTools',
221
245
  'sessionSweep', 'searchUp', 'badFilePolicy', 'maxChainLength',
222
246
  'networkEnabled', 'networkMode', 'networkUnlisted', 'networkUnattributed', 'networkLoopback',
223
247
  'networkBind', 'networkPort', 'networkNoProxy', 'networkInjectEnv', 'networkAskTimeoutMs',
package/lib/evaluate.js CHANGED
@@ -105,9 +105,14 @@ export function ruleMatches(rule, ctx, commands, pathCands, urlCands) {
105
105
  // Join the FULL argv of each simple command: `c.command` alone is just the
106
106
  // command word, so `curl https://x | sh` would read as `curl|sh` and the
107
107
  // documented `curl|sh` pattern could never see its own subject.
108
- const pipelineText = commands.map((c) => commandArgv(c)).join('|');
108
+ const argvs = commands.map((c) => commandArgv(c));
109
+ const pipelineText = argvs.join('|');
109
110
  const compiled = rule.argv.pipeline.map((p) => compileGlobForMatch(p));
110
- if (!compiled.some((g) => g.test(pipelineText)))
111
+ // Match per simple command as well as over the joined pipeline: anchored
112
+ // prefix patterns (`git reset --hard*`) must see each segment on its own —
113
+ // `cd x && git reset --hard` hides the guarded segment behind the join —
114
+ // while cross-segment patterns (`curl|sh`) still read the joined string.
115
+ if (!compiled.some((g) => g.test(pipelineText) || argvs.some((s) => g.test(s))))
111
116
  return false;
112
117
  }
113
118
  // network dimension: all present sub-dimensions must match.
package/lib/index.js CHANGED
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { SandboxController } from './appcontainer/index.js';
4
4
  import { Config, ensureDataDir, readRulesFromSettings, readVolatileValue, resolveVolatileConfig, resolveConfig, resolveDshHome, resolveDataDir, resolveGatePresets, resolvePermissiveStrategies, resolveRulesFile } from './config.js';
5
5
  import { appendAllowToSettings, readRulesFileDoc, replaceAllowInSettings } from './allowlist.js';
6
+ import { mergePresetIntoRaw } from './preset-rules.js';
6
7
  import { runDryRun } from './dry-run.js';
7
8
  import { registerDryRunRoute, registerEventsRoute, registerHealthRoute, registerLearningRoute, registerNetworkRoute, registerReceiverRoute, registerReviewRoutes, registerRulesRoute } from './events.js';
8
9
  // T12 设置数据桥(describe/mutate;client BridgeDocHandle 消费)
@@ -206,6 +207,25 @@ export function apply(ctx, config = {}) {
206
207
  // the ref unset and the runtime falls back to its rules-file write paths.
207
208
  const ENTRY_ID = 'dsh-perm-gate';
208
209
  const settingsRef = {};
210
+ // 预设拒绝表播种:一次性、只走宿主设置面(写回落进 profile patch 的本插件
211
+ // config 条目)。刻意不再写插件自有的 `perm-gate/rules.yml` —— 那是用户手维护的
212
+ // 规则源,混进 51 条预设会让「我的规则」与「插件注入的规则」不可区分。
213
+ // 两条经实测定下的约束:① 必须在各分支「注册完成之后」调(0.1.5 上 ns 要等
214
+ // installSection 才注册,提前写报 `settings namespace ... is not registered`);
215
+ // ② 规则与幂等位必须合成一次 update(拆两次时 0.1.7 第二次报 `Plugin entry ... is
216
+ // no longer configurable`,幂等位永远落不下去 ⇒ 用户全删后会被重新塞回)。
217
+ // 写失败不阻断启动也不置幂等位 ⇒ 下次激活重试。
218
+ const seedPresetDenyTable = (svc) => {
219
+ void (async () => {
220
+ if (current().presetRulesSeeded === true)
221
+ return;
222
+ const merged = mergePresetIntoRaw(readVolatileValue(config.rules));
223
+ await svc.update(ENTRY_ID, { rules: merged.doc, presetRulesSeeded: true });
224
+ console.log(`[dsh-perm-gate] preset deny table seeded via settings (+${merged.added} rules)`);
225
+ })().catch((e) => {
226
+ console.warn('[dsh-perm-gate] preset deny table 播种失败(不阻断启动,下次激活重试):', e);
227
+ });
228
+ };
209
229
  ctx.inject(['settings'], (child) => {
210
230
  const svc = child.settings;
211
231
  settingsRef.svc = svc;
@@ -253,6 +273,7 @@ export function apply(ctx, config = {}) {
253
273
  console.warn('[dsh-perm-gate] legacy seed update failed:', e);
254
274
  });
255
275
  }
276
+ seedPresetDenyTable(svc);
256
277
  return;
257
278
  }
258
279
  void (async () => {
@@ -279,6 +300,7 @@ export function apply(ctx, config = {}) {
279
300
  console.warn('[dsh-perm-gate] legacy seed update failed:', e);
280
301
  });
281
302
  }
303
+ seedPresetDenyTable(svc);
282
304
  return;
283
305
  }
284
306
  }
@@ -306,6 +328,7 @@ export function apply(ctx, config = {}) {
306
328
  console.warn('[dsh-perm-gate] rules seed write failed:', e);
307
329
  });
308
330
  }
331
+ seedPresetDenyTable(svc);
309
332
  })();
310
333
  });
311
334
  const settingsWrite = (write) => {
@@ -326,6 +349,7 @@ export function apply(ctx, config = {}) {
326
349
  // entry that omits `config` still records events, snapshots and learning.
327
350
  const dataDir = resolveDataDir(typeof config.dshHome === 'string' ? config.dshHome : undefined);
328
351
  const rulesFilePath = resolveRulesFile(typeof config.rulesFile === 'string' ? config.rulesFile : undefined, dataDir);
352
+ // 预设拒绝表的播种在上面的 settings inject 回调里(那里才有 svc)。
329
353
  // Host model-group services (typed minimally; supplied by the dsh runtime
330
354
  // through the loader `inject` — dsh-approval-gate demonstrates the same
331
355
  // contract). The llm service backs the `host` receiver; agentDefaultModel
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Preset deny rule table for dsh-perm-gate.
3
+ *
4
+ * Ported from cc-safety-net (MIT, https://github.com/kenryu42/cc-safety-net)
5
+ * `src/core/rules/destructive.ts` + `secret.ts`, translated into this plugin's
6
+ * own `permissions` YAML vocabulary (see rule.ts / rule-dims.ts). The catalog
7
+ * is ONE SHOT-SEEDED preset: on first activation the entries are appended to
8
+ * the tail of the live rules document's deny/ask lists — the settings `rules`
9
+ * field when it is configured, otherwise the rules file. Revocation is
10
+ * user-owned: every entry carries a `[deny-rule:<id>]` tag and can be flipped
11
+ * to `enabled: false` or deleted in the settings card or the file; the
12
+ * `presetRulesSeeded` config flag keeps the seed from ever re-adding them.
13
+ *
14
+ * Policy decisions baked into this table (auditable history in
15
+ * .agents/plans/compat-cc-safety-net-deny-table/):
16
+ * - `git push --force` → ask, not deny (user decision 2026-10-13; also sidesteps
17
+ * the glob negation gap for --force-with-lease).
18
+ * - Dynamic/unverifiable targets (cc's strict-mode fail-closed family) → ask,
19
+ * aligning with this gate's existing "undecidable → ask" philosophy.
20
+ * - Secret protection keeps this gate's ruling: read = ask, write/delete = deny,
21
+ * read+egress combos stay in the prompt layer (layered-defense decision).
22
+ * - Root/home wipes and full-drive Remove-Item are NOT duplicated here — the P0
23
+ * irreversible layer runs first and owns them.
24
+ * - The legacy deny-keyword blacklist (deny-defaults.ts) stays as a parallel
25
+ * layer; nothing here replaces it.
26
+ *
27
+ * Pure + import-light (yaml + rule.ts only): the client bundle may render the
28
+ * table from `presetEntriesForView()` without pulling in the node filesystem.
29
+ */
30
+ import { type PermissionsDoc } from './rule.js';
31
+ /** Tag prefix carried by every preset entry's reason; locates an entry for revocation. */
32
+ export declare const PRESET_RULE_MARKER = "[deny-rule:";
33
+ /** Extract the `[deny-rule:<id>]` tag from one reason string (undefined when untagged). */
34
+ export declare function presetRuleId(reason: unknown): string | undefined;
35
+ /**
36
+ * The preset document in the file's bare YAML form. `defaultAction: ask`
37
+ * matches the empty-ruleset default, so seeding cannot widen anything.
38
+ * Patterns obey the compile limits: ≤2 glob stars, ordered subcommand+flag
39
+ * forms through argv.pipeline (evaluated per simple command as well as over
40
+ * the joined pipeline — see evaluate.ts), token co-occurrence NEVER through
41
+ * args (args is any-token OR).
42
+ */
43
+ export declare const PRESET_RULES_YAML = "defaultAction: ask\n\ndeny:\n # \u2500\u2500 Git \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n - command: [git]\n argv: { pipeline: [\"git reset --hard*\", \"git reset *--hard*\"] }\n reason: \"[deny-rule:git.reset-hard] Hard reset discards uncommitted changes. Use git reset --soft/--mixed, or git stash first.\"\n - command: [git]\n argv: { pipeline: [\"git reset --merge*\", \"git reset --abort*\"] }\n reason: \"[deny-rule:git.reset-merge] Merge reset discards in-progress merge work. Stash first, then reset --soft/--mixed.\"\n - command: [git]\n argv: { pipeline: [\"git clean *-f*\", \"git clean *--force*\"] }\n reason: \"[deny-rule:git.clean-force] git clean permanently deletes untracked files. Dry-run first: git clean -nd.\"\n - command: [git]\n argv: { pipeline: [\"git push origin *--delete*\", \"git push * --delete *\", \"git push --delete*\"] }\n reason: \"[deny-rule:git.push-delete] Deleting remote refs is visible to collaborators and needs explicit consent.\"\n - command: [git]\n argv: { pipeline: [\"git push --mirror*\", \"git push * --mirror\"] }\n reason: \"[deny-rule:git.push-mirror] Mirror push force-updates and deletes remote refs wholesale.\"\n - command: [git]\n argv: { pipeline: [\"git branch -D*\", \"git branch * -D\", \"git branch *--force-delete*\"] }\n reason: \"[deny-rule:git.branch-force-delete] git branch -D drops unmerged commits. Prefer merged-safe git branch -d.\"\n - command: [git]\n argv: { pipeline: [\"git stash drop*\"] }\n reason: \"[deny-rule:git.stash-drop] Dropping a stash entry is unrecoverable after GC. Verify with git stash list.\"\n - command: [git]\n argv: { pipeline: [\"git stash clear*\"] }\n reason: \"[deny-rule:git.stash-clear] Clearing all stashes destroys every saved worktree state.\"\n - command: [git]\n argv: { pipeline: [\"git tag -d*\", \"git tag --delete*\"] }\n reason: \"[deny-rule:git.tag-delete] Tag deletion removes release anchors.\"\n - command: [git]\n argv: { pipeline: [\"git reflog delete*\", \"git reflog expire*\"] }\n reason: \"[deny-rule:git.reflog-delete] Reflog deletion removes the last recovery path for lost commits.\"\n - command: [git]\n argv: { pipeline: [\"git rebase --abort*\"] }\n reason: \"[deny-rule:git.rebase-abort] Aborting a rebase discards resolution work. Prefer --continue/--skip.\"\n - command: [git]\n argv: { pipeline: [\"git merge --abort*\"] }\n reason: \"[deny-rule:git.merge-abort] Aborting a merge discards conflict resolution.\"\n - command: [git]\n argv: { pipeline: [\"git rm -rf *\", \"git rm *-rf *\", \"git rm * -rf\", \"git rm -fr *\", \"git rm --force *\", \"git rm * --force\", \"git rm -f *\", \"git rm * -f\"] }\n reason: \"[deny-rule:git.rm-force] git rm -f deletes working-tree files even when modified. Stash them first.\"\n - command: [git]\n argv: { pipeline: [\"git checkout --force *\", \"git checkout * --force\", \"git checkout -f *\", \"git checkout * -f\", \"git checkout -f\", \"git checkout * --force *\"] }\n reason: \"[deny-rule:git.checkout-force] Forced checkout overwrites local changes. git stash, then checkout.\"\n - command: [git]\n argv: { pipeline: [\"git checkout -- *\"] }\n reason: \"[deny-rule:git.checkout-double-dash] Path restore from checkout discards local edits. Use git stash push -- <path>.\"\n - command: [git]\n argv: { pipeline: [\"git checkout * -- *\"] }\n reason: \"[deny-rule:git.checkout-ref-path] Checking out a ref with a path overwrites working-tree files. Stash the paths first.\"\n - command: [git]\n argv: { pipeline: [\"git checkout *--pathspec-from-file*\"] }\n reason: \"[deny-rule:git.checkout-pathspec-from-file] Bulk path restore from a file can silently overwrite edits. List the pathspec, stash, then restore.\"\n - command: [git]\n argv: { pipeline: [\"git switch *--discard-changes*\"] }\n reason: \"[deny-rule:git.switch-discard-changes] --discard-changes throws away edits. git stash first.\"\n - command: [git]\n argv: { pipeline: [\"git switch --force *\", \"git switch * --force\", \"git switch -f *\", \"git switch * -f\"] }\n reason: \"[deny-rule:git.switch-force] Forced switch overwrites local changes. git stash first.\"\n - command: [git]\n # git restore <bare path> (the plain unstaged discard) is NOT ported here:\n # any glob broad enough to catch it also catches the harmless\n # git restore --staged <path> (G6) \u2014 only the explicit discard forms are\n # denied, the bare-path case stays in the prompt layer.\n argv: { pipeline: [\"git restore --worktree *\", \"git restore * --worktree *\", \"git restore * --worktree\", \"git restore -W *\"] }\n reason: \"[deny-rule:git.restore-worktree] Worktree restore discards local edits. git stash push -- <path> first.\"\n - command: [git]\n argv: { pipeline: [\"git worktree remove --force *\", \"git worktree remove * --force\", \"git worktree remove -f *\", \"git worktree remove * -f\"] }\n reason: \"[deny-rule:git.worktree-remove-force] Forced worktree removal discards uncommitted work in that tree.\"\n - command: [git]\n args: [\"alias.*\"]\n reason: \"[deny-rule:git.alias-config] Command-line Git aliases run arbitrary shell and cannot be analyzed.\"\n # params matches the RAW command string: the env-assignment word lands as the\n # simple command's lowercased word, so a command:[git] dimension would never\n # see this form \u2014 match on params alone.\n - params: { command: [\"*GIT_SSH_COMMAND=*\", \"*git_ssh_command=*\", \"*GIT_SSH=*\", \"*git_ssh=*\"] }\n reason: \"[deny-rule:git.ssh-env] Git network ops with an SSH env override execute unanalyzable code.\"\n\n # \u2500\u2500 Filesystem \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n - command: [\"rm#recursive\", \"rm#force\"]\n args: [\".git\", \".git/*\", \"*/.git\"]\n reason: \"[deny-rule:rm.git-metadata] Removing Git metadata destroys history and the gate's audit anchors.\"\n - command: [find]\n argv: { pipeline: [\"find .git*\"] }\n reason: \"[deny-rule:find.delete-git-metadata] Selecting .git for deletion destroys repository state.\"\n - command: [find]\n argv: { pipeline: [\"find * -delete\"] }\n reason: \"[deny-rule:find.delete] find -delete removes everything selected. Enumerate with plain find first.\"\n - command: [find]\n argv: { pipeline: [\"find *-exec rm*\", \"find *-delete*\"] }\n reason: \"[deny-rule:find.exec-rm-recursive-force] find -exec rm removes the whole tree per match. Dry-run plain find, delete explicit paths.\"\n - command: [dd]\n argv: { pipeline: [\"dd * of=/dev/sd*\", \"dd * of=/dev/nvme*\", \"dd * of=/dev/mmcblk*\"] }\n reason: \"[deny-rule:dd.device-write] dd writing to a block device destroys the disk.\"\n - command: [mkfs, mke2fs, newfs, \"mkfs.*\"]\n reason: \"[deny-rule:mkfs.device] Formatting a device erases it.\"\n - command: [diskutil, wipefs, sgdisk, parted]\n reason: \"[deny-rule:disk.erase] Disk erase/partition/wipe operations destroy data.\"\n - command: [shred]\n reason: \"[deny-rule:shred.target] shred irreversibly destroys files (even --help/--version reach it).\"\n - command: [cat, head, tail, dd]\n args: [\"*/dev/sd*\", \"*/dev/nvme*\", \"*/dev/disk*\", \"*/dev/mmcblk*\"]\n reason: \"[deny-rule:redirect.block-device] Redirect target resolving to a disk device path.\"\n\n # \u2500\u2500 PowerShell \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n # Command words are lowercased by the decomposer \u2014 command dimensions must\n # carry the lowercased verb (Remove-Item \u2192 remove-item).\n - command: [\"remove-item#recursive\"]\n args: [\".git\", \".git/*\", \"*/.git\"]\n reason: \"[deny-rule:powershell.remove-item-git-metadata] Never target .git.\"\n - command: [powershell, pwsh]\n argv: { pipeline: [\"pwsh *Remove-Item*\", \"powershell *Remove-Item*\", \"pwsh *remove-item*\", \"powershell *remove-item*\"] }\n reason: \"[deny-rule:powershell.nested-recursive-delete-unread] A delete handed to pwsh/powershell in a non-literal form cannot be verified before it runs.\"\n - command: [\"remove-item#recursive\"]\n args: [\".\", \"./\"]\n reason: \"[deny-rule:powershell.remove-item-recursive-force-cwd-self] Deleting the current directory removes the project.\"\n - command: [\"remove-item#recursive\"]\n args: [\"../*\", \"..\\\\*\"]\n reason: \"[deny-rule:powershell.remove-item-recursive-force-outside-cwd] Deleting above the cwd escapes task scope.\"\n\n # \u2500\u2500 Command Prompt \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n # (The cc escaped-quote-collapse rule cannot be expressed faithfully with\n # these globs \u2014 every cmd /c recursion is nested behind a second parser,\n # so its true target is undecidable: it moves to the ask section instead.)\n - command: [cmd, rmdir, del]\n args: [\".git\", \".git/*\", \"*/.git\"]\n reason: \"[deny-rule:cmd.recursive-delete-git-metadata] Never target .git.\"\n - command: [rmdir, del]\n args: [\".\", \"./\"]\n reason: \"[deny-rule:cmd.recursive-delete-cwd-self] Deleting the current directory removes the project.\"\n - command: [rmdir, del]\n args: [\"../*\", \"..\\\\*\"]\n reason: \"[deny-rule:cmd.recursive-delete-outside-cwd] Delete above cwd escapes task scope.\"\n\n # \u2500\u2500 Secret targets (write/delete deny; reads are the ask section below) \u2500\u2500\n - command: [\"rm#recursive\", \"remove-item#recursive\", rmdir, del]\n args: [\"*/.ssh\", \"*/.aws\", \"*/.gnupg\", \".ssh\", \".aws\"]\n reason: \"[deny-rule:secret.delete-credential-path] Deleting credential stores destroys keys you cannot regenerate.\"\n - command: [echo, \"set-content\", \"out-file\"]\n args: [\".env\", \".env.*\"]\n reason: \"[deny-rule:secret.write-overwrite-env] Overwriting .env destroys existing secrets. Edit specific lines.\"\n - command: [\"rm#recursive\", \"remove-item#recursive\", rmdir, del]\n args: [\"*/.git-credentials\", \".git-credentials\"]\n reason: \"[deny-rule:secret.delete-git-credentials] Deleting the git credential store breaks push/pull auth.\"\n\nask:\n # Force push: ask, not deny (revoked from cc's deny ruling 2026-10-13).\n - command: [git]\n argv: { pipeline: [\"git push *--force*\", \"git push *-f*\"] }\n reason: \"[deny-rule:git.push-force] Force push rewrites shared history. Prefer git push --force-with-lease.\"\n\n # cmd /c recursion hides the real target behind a second parser (and quoting):\n # undecidable \u2192 ask, where cc denied only the drive-root-collapse variant.\n - command: [cmd]\n argv: { pipeline: [\"cmd /c *rmdir /s*\", \"cmd /c *del /s*\"] }\n reason: \"[deny-rule:cmd.recursive-delete-nested] cmd /c recursion nests a second parser; the real target cannot be resolved. Run rmdir/del directly so the gate can see the target.\"\n\n # Dynamic targets: this gate's ruling is \"undecidable \u2192 ask\", not fail-closed deny.\n - command: [\"rm#recursive\", \"remove-item#recursive\"]\n args: [\"$*\"]\n reason: \"[deny-rule:rm.recursive-force-dynamic-target] Recursive delete of a substitution-assembled target \u2014 verify the variable yourself.\"\n # The wrapper word xargs is stripped by the decomposer, so pipeline patterns\n # never see it \u2014 match the raw command string through the params dimension.\n - params: { command: [\"*xargs rm*\", \"*xargs del*\"] }\n reason: \"[deny-rule:xargs.rm-recursive-force-dynamic] xargs-fed removals have unverifiable target sets.\"\n - command: [parallel]\n argv: { pipeline: [\"parallel *rm*\", \"*| parallel*\"] }\n reason: \"[deny-rule:parallel.rm-recursive-force-dynamic] parallel-fed rm has unresolvable targets.\"\n - command: [awk]\n args: [\"*system(*\"]\n reason: \"[deny-rule:awk.system-dynamic] awk program text containing system() cannot be analyzed.\"\n # NOTE: cc's git checkout <ref> <path> ambiguity (G5) is NOT ported \u2014 a glob\n # cannot tell ref candidates from paths, and a broad pattern would prompt on\n # every git checkout -b new-branch. Documented residual gap.\n\n # Credential reads: ask (A2 ruling kept; egress combos stay in the prompt layer).\n - command: [cat, head, tail, type, \"get-content\", ls, dir, \"get-childitem\", \"select-string\"]\n args: [\".env\", \".env.*\", \"*.env\", \".npmrc\", \".pypirc\", \".netrc\", \".git-credentials\", \"id_rsa\", \"id_ed25519\", \"id_ecdsa\"]\n reason: \"[deny-rule:secret.read-credential-file] Reading a credential file requires confirmation. Echo only the keys you need via a filtered command.\"\n - command: [cat, head, tail, type, \"get-content\", ls, dir, \"get-childitem\"]\n args: [\"*/.ssh\", \"*/.ssh/*\", \"*/.aws\", \"*/.aws/*\", \"*/.gnupg\", \"*/.gnupg/*\", \"*/.config/gcloud/*\", \"*/.claude/*\", \"*/.codex/*\"]\n reason: \"[deny-rule:secret.read-home-credential-dirs] Browsing ssh/cloud/coding-CLI credential stores requires confirmation.\"\n\n # Self-protection: editing the gate's own rule sources needs confirmation \u2014\n # revocation must be a human act, not something an agent can slide through.\n # (Settings-namespace edits go through the host UI, not a tool call, and are\n # out of reach of this entry; documented gap.)\n - args: [\"*rules.yml\", \"*rules.yaml\", \"*deny-defaults*\"]\n reason: \"[deny-rule:gate.self-protection] This call touches the permission gate's own rule sources; changes here alter what the gate enforces.\"\n";
44
+ export declare function parsePresetDoc(): PermissionsDoc;
45
+ /** Every preset id, in table order. Client view + tests use this as the row key. */
46
+ export declare function presetRuleIds(): string[];
47
+ /**
48
+ * Append missing preset entries to a RAW rules document object (bare or
49
+ * `permissions:`-wrapped form). Idempotent and revocation-preserving: an id
50
+ * already present — enabled or not — is never re-added. Mutates nothing;
51
+ * returns the merged copy and the added count.
52
+ */
53
+ export declare function mergePresetIntoRaw(rawDoc: unknown): {
54
+ doc: Record<string, unknown>;
55
+ added: number;
56
+ };
57
+ /**
58
+ * One-shot file seed: create the rules file from the preset when absent, or
59
+ * append missing preset entries when present (comments in a hand-written file
60
+ * are lost on rewrite — the file is the plugin's data dir, not user-authored
61
+ * by default). A sibling marker file records that the seed ran: after a full
62
+ * revocation (user deletes every preset entry) the seed must never re-add
63
+ * them, and the marker works on hosts without the settings service where the
64
+ * `presetRulesSeeded` config flag cannot be persisted. Returns what happened
65
+ * for the log line.
66
+ */
67
+ export declare function ensurePresetRulesFile(rulesFile: string, markerFile?: string): {
68
+ action: 'created' | 'merged' | 'skipped' | 'already-seeded';
69
+ added: number;
70
+ };
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Preset deny rule table for dsh-perm-gate.
3
+ *
4
+ * Ported from cc-safety-net (MIT, https://github.com/kenryu42/cc-safety-net)
5
+ * `src/core/rules/destructive.ts` + `secret.ts`, translated into this plugin's
6
+ * own `permissions` YAML vocabulary (see rule.ts / rule-dims.ts). The catalog
7
+ * is ONE SHOT-SEEDED preset: on first activation the entries are appended to
8
+ * the tail of the live rules document's deny/ask lists — the settings `rules`
9
+ * field when it is configured, otherwise the rules file. Revocation is
10
+ * user-owned: every entry carries a `[deny-rule:<id>]` tag and can be flipped
11
+ * to `enabled: false` or deleted in the settings card or the file; the
12
+ * `presetRulesSeeded` config flag keeps the seed from ever re-adding them.
13
+ *
14
+ * Policy decisions baked into this table (auditable history in
15
+ * .agents/plans/compat-cc-safety-net-deny-table/):
16
+ * - `git push --force` → ask, not deny (user decision 2026-10-13; also sidesteps
17
+ * the glob negation gap for --force-with-lease).
18
+ * - Dynamic/unverifiable targets (cc's strict-mode fail-closed family) → ask,
19
+ * aligning with this gate's existing "undecidable → ask" philosophy.
20
+ * - Secret protection keeps this gate's ruling: read = ask, write/delete = deny,
21
+ * read+egress combos stay in the prompt layer (layered-defense decision).
22
+ * - Root/home wipes and full-drive Remove-Item are NOT duplicated here — the P0
23
+ * irreversible layer runs first and owns them.
24
+ * - The legacy deny-keyword blacklist (deny-defaults.ts) stays as a parallel
25
+ * layer; nothing here replaces it.
26
+ *
27
+ * Pure + import-light (yaml + rule.ts only): the client bundle may render the
28
+ * table from `presetEntriesForView()` without pulling in the node filesystem.
29
+ */
30
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
31
+ import { parse, stringify } from 'yaml';
32
+ import { parsePermissionsDocument } from './rule.js';
33
+ /** Tag prefix carried by every preset entry's reason; locates an entry for revocation. */
34
+ export const PRESET_RULE_MARKER = '[deny-rule:';
35
+ /** Extract the `[deny-rule:<id>]` tag from one reason string (undefined when untagged). */
36
+ export function presetRuleId(reason) {
37
+ if (typeof reason !== 'string')
38
+ return undefined;
39
+ const m = /\[deny-rule:([a-z0-9._-]+)\]/.exec(reason);
40
+ return m?.[1];
41
+ }
42
+ /**
43
+ * The preset document in the file's bare YAML form. `defaultAction: ask`
44
+ * matches the empty-ruleset default, so seeding cannot widen anything.
45
+ * Patterns obey the compile limits: ≤2 glob stars, ordered subcommand+flag
46
+ * forms through argv.pipeline (evaluated per simple command as well as over
47
+ * the joined pipeline — see evaluate.ts), token co-occurrence NEVER through
48
+ * args (args is any-token OR).
49
+ */
50
+ export const PRESET_RULES_YAML = `defaultAction: ask
51
+
52
+ deny:
53
+ # ── Git ──────────────────────────────────────────────────────────────────
54
+ - command: [git]
55
+ argv: { pipeline: ["git reset --hard*", "git reset *--hard*"] }
56
+ reason: "[deny-rule:git.reset-hard] Hard reset discards uncommitted changes. Use git reset --soft/--mixed, or git stash first."
57
+ - command: [git]
58
+ argv: { pipeline: ["git reset --merge*", "git reset --abort*"] }
59
+ reason: "[deny-rule:git.reset-merge] Merge reset discards in-progress merge work. Stash first, then reset --soft/--mixed."
60
+ - command: [git]
61
+ argv: { pipeline: ["git clean *-f*", "git clean *--force*"] }
62
+ reason: "[deny-rule:git.clean-force] git clean permanently deletes untracked files. Dry-run first: git clean -nd."
63
+ - command: [git]
64
+ argv: { pipeline: ["git push origin *--delete*", "git push * --delete *", "git push --delete*"] }
65
+ reason: "[deny-rule:git.push-delete] Deleting remote refs is visible to collaborators and needs explicit consent."
66
+ - command: [git]
67
+ argv: { pipeline: ["git push --mirror*", "git push * --mirror"] }
68
+ reason: "[deny-rule:git.push-mirror] Mirror push force-updates and deletes remote refs wholesale."
69
+ - command: [git]
70
+ argv: { pipeline: ["git branch -D*", "git branch * -D", "git branch *--force-delete*"] }
71
+ reason: "[deny-rule:git.branch-force-delete] git branch -D drops unmerged commits. Prefer merged-safe git branch -d."
72
+ - command: [git]
73
+ argv: { pipeline: ["git stash drop*"] }
74
+ reason: "[deny-rule:git.stash-drop] Dropping a stash entry is unrecoverable after GC. Verify with git stash list."
75
+ - command: [git]
76
+ argv: { pipeline: ["git stash clear*"] }
77
+ reason: "[deny-rule:git.stash-clear] Clearing all stashes destroys every saved worktree state."
78
+ - command: [git]
79
+ argv: { pipeline: ["git tag -d*", "git tag --delete*"] }
80
+ reason: "[deny-rule:git.tag-delete] Tag deletion removes release anchors."
81
+ - command: [git]
82
+ argv: { pipeline: ["git reflog delete*", "git reflog expire*"] }
83
+ reason: "[deny-rule:git.reflog-delete] Reflog deletion removes the last recovery path for lost commits."
84
+ - command: [git]
85
+ argv: { pipeline: ["git rebase --abort*"] }
86
+ reason: "[deny-rule:git.rebase-abort] Aborting a rebase discards resolution work. Prefer --continue/--skip."
87
+ - command: [git]
88
+ argv: { pipeline: ["git merge --abort*"] }
89
+ reason: "[deny-rule:git.merge-abort] Aborting a merge discards conflict resolution."
90
+ - command: [git]
91
+ argv: { pipeline: ["git rm -rf *", "git rm *-rf *", "git rm * -rf", "git rm -fr *", "git rm --force *", "git rm * --force", "git rm -f *", "git rm * -f"] }
92
+ reason: "[deny-rule:git.rm-force] git rm -f deletes working-tree files even when modified. Stash them first."
93
+ - command: [git]
94
+ argv: { pipeline: ["git checkout --force *", "git checkout * --force", "git checkout -f *", "git checkout * -f", "git checkout -f", "git checkout * --force *"] }
95
+ reason: "[deny-rule:git.checkout-force] Forced checkout overwrites local changes. git stash, then checkout."
96
+ - command: [git]
97
+ argv: { pipeline: ["git checkout -- *"] }
98
+ reason: "[deny-rule:git.checkout-double-dash] Path restore from checkout discards local edits. Use git stash push -- <path>."
99
+ - command: [git]
100
+ argv: { pipeline: ["git checkout * -- *"] }
101
+ reason: "[deny-rule:git.checkout-ref-path] Checking out a ref with a path overwrites working-tree files. Stash the paths first."
102
+ - command: [git]
103
+ argv: { pipeline: ["git checkout *--pathspec-from-file*"] }
104
+ reason: "[deny-rule:git.checkout-pathspec-from-file] Bulk path restore from a file can silently overwrite edits. List the pathspec, stash, then restore."
105
+ - command: [git]
106
+ argv: { pipeline: ["git switch *--discard-changes*"] }
107
+ reason: "[deny-rule:git.switch-discard-changes] --discard-changes throws away edits. git stash first."
108
+ - command: [git]
109
+ argv: { pipeline: ["git switch --force *", "git switch * --force", "git switch -f *", "git switch * -f"] }
110
+ reason: "[deny-rule:git.switch-force] Forced switch overwrites local changes. git stash first."
111
+ - command: [git]
112
+ # git restore <bare path> (the plain unstaged discard) is NOT ported here:
113
+ # any glob broad enough to catch it also catches the harmless
114
+ # git restore --staged <path> (G6) — only the explicit discard forms are
115
+ # denied, the bare-path case stays in the prompt layer.
116
+ argv: { pipeline: ["git restore --worktree *", "git restore * --worktree *", "git restore * --worktree", "git restore -W *"] }
117
+ reason: "[deny-rule:git.restore-worktree] Worktree restore discards local edits. git stash push -- <path> first."
118
+ - command: [git]
119
+ argv: { pipeline: ["git worktree remove --force *", "git worktree remove * --force", "git worktree remove -f *", "git worktree remove * -f"] }
120
+ reason: "[deny-rule:git.worktree-remove-force] Forced worktree removal discards uncommitted work in that tree."
121
+ - command: [git]
122
+ args: ["alias.*"]
123
+ reason: "[deny-rule:git.alias-config] Command-line Git aliases run arbitrary shell and cannot be analyzed."
124
+ # params matches the RAW command string: the env-assignment word lands as the
125
+ # simple command's lowercased word, so a command:[git] dimension would never
126
+ # see this form — match on params alone.
127
+ - params: { command: ["*GIT_SSH_COMMAND=*", "*git_ssh_command=*", "*GIT_SSH=*", "*git_ssh=*"] }
128
+ reason: "[deny-rule:git.ssh-env] Git network ops with an SSH env override execute unanalyzable code."
129
+
130
+ # ── Filesystem ───────────────────────────────────────────────────────────
131
+ - command: ["rm#recursive", "rm#force"]
132
+ args: [".git", ".git/*", "*/.git"]
133
+ reason: "[deny-rule:rm.git-metadata] Removing Git metadata destroys history and the gate's audit anchors."
134
+ - command: [find]
135
+ argv: { pipeline: ["find .git*"] }
136
+ reason: "[deny-rule:find.delete-git-metadata] Selecting .git for deletion destroys repository state."
137
+ - command: [find]
138
+ argv: { pipeline: ["find * -delete"] }
139
+ reason: "[deny-rule:find.delete] find -delete removes everything selected. Enumerate with plain find first."
140
+ - command: [find]
141
+ argv: { pipeline: ["find *-exec rm*", "find *-delete*"] }
142
+ reason: "[deny-rule:find.exec-rm-recursive-force] find -exec rm removes the whole tree per match. Dry-run plain find, delete explicit paths."
143
+ - command: [dd]
144
+ argv: { pipeline: ["dd * of=/dev/sd*", "dd * of=/dev/nvme*", "dd * of=/dev/mmcblk*"] }
145
+ reason: "[deny-rule:dd.device-write] dd writing to a block device destroys the disk."
146
+ - command: [mkfs, mke2fs, newfs, "mkfs.*"]
147
+ reason: "[deny-rule:mkfs.device] Formatting a device erases it."
148
+ - command: [diskutil, wipefs, sgdisk, parted]
149
+ reason: "[deny-rule:disk.erase] Disk erase/partition/wipe operations destroy data."
150
+ - command: [shred]
151
+ reason: "[deny-rule:shred.target] shred irreversibly destroys files (even --help/--version reach it)."
152
+ - command: [cat, head, tail, dd]
153
+ args: ["*/dev/sd*", "*/dev/nvme*", "*/dev/disk*", "*/dev/mmcblk*"]
154
+ reason: "[deny-rule:redirect.block-device] Redirect target resolving to a disk device path."
155
+
156
+ # ── PowerShell ───────────────────────────────────────────────────────────
157
+ # Command words are lowercased by the decomposer — command dimensions must
158
+ # carry the lowercased verb (Remove-Item → remove-item).
159
+ - command: ["remove-item#recursive"]
160
+ args: [".git", ".git/*", "*/.git"]
161
+ reason: "[deny-rule:powershell.remove-item-git-metadata] Never target .git."
162
+ - command: [powershell, pwsh]
163
+ argv: { pipeline: ["pwsh *Remove-Item*", "powershell *Remove-Item*", "pwsh *remove-item*", "powershell *remove-item*"] }
164
+ reason: "[deny-rule:powershell.nested-recursive-delete-unread] A delete handed to pwsh/powershell in a non-literal form cannot be verified before it runs."
165
+ - command: ["remove-item#recursive"]
166
+ args: [".", "./"]
167
+ reason: "[deny-rule:powershell.remove-item-recursive-force-cwd-self] Deleting the current directory removes the project."
168
+ - command: ["remove-item#recursive"]
169
+ args: ["../*", "..\\\\*"]
170
+ reason: "[deny-rule:powershell.remove-item-recursive-force-outside-cwd] Deleting above the cwd escapes task scope."
171
+
172
+ # ── Command Prompt ───────────────────────────────────────────────────────
173
+ # (The cc escaped-quote-collapse rule cannot be expressed faithfully with
174
+ # these globs — every cmd /c recursion is nested behind a second parser,
175
+ # so its true target is undecidable: it moves to the ask section instead.)
176
+ - command: [cmd, rmdir, del]
177
+ args: [".git", ".git/*", "*/.git"]
178
+ reason: "[deny-rule:cmd.recursive-delete-git-metadata] Never target .git."
179
+ - command: [rmdir, del]
180
+ args: [".", "./"]
181
+ reason: "[deny-rule:cmd.recursive-delete-cwd-self] Deleting the current directory removes the project."
182
+ - command: [rmdir, del]
183
+ args: ["../*", "..\\\\*"]
184
+ reason: "[deny-rule:cmd.recursive-delete-outside-cwd] Delete above cwd escapes task scope."
185
+
186
+ # ── Secret targets (write/delete deny; reads are the ask section below) ──
187
+ - command: ["rm#recursive", "remove-item#recursive", rmdir, del]
188
+ args: ["*/.ssh", "*/.aws", "*/.gnupg", ".ssh", ".aws"]
189
+ reason: "[deny-rule:secret.delete-credential-path] Deleting credential stores destroys keys you cannot regenerate."
190
+ - command: [echo, "set-content", "out-file"]
191
+ args: [".env", ".env.*"]
192
+ reason: "[deny-rule:secret.write-overwrite-env] Overwriting .env destroys existing secrets. Edit specific lines."
193
+ - command: ["rm#recursive", "remove-item#recursive", rmdir, del]
194
+ args: ["*/.git-credentials", ".git-credentials"]
195
+ reason: "[deny-rule:secret.delete-git-credentials] Deleting the git credential store breaks push/pull auth."
196
+
197
+ ask:
198
+ # Force push: ask, not deny (revoked from cc's deny ruling 2026-10-13).
199
+ - command: [git]
200
+ argv: { pipeline: ["git push *--force*", "git push *-f*"] }
201
+ reason: "[deny-rule:git.push-force] Force push rewrites shared history. Prefer git push --force-with-lease."
202
+
203
+ # cmd /c recursion hides the real target behind a second parser (and quoting):
204
+ # undecidable → ask, where cc denied only the drive-root-collapse variant.
205
+ - command: [cmd]
206
+ argv: { pipeline: ["cmd /c *rmdir /s*", "cmd /c *del /s*"] }
207
+ reason: "[deny-rule:cmd.recursive-delete-nested] cmd /c recursion nests a second parser; the real target cannot be resolved. Run rmdir/del directly so the gate can see the target."
208
+
209
+ # Dynamic targets: this gate's ruling is "undecidable → ask", not fail-closed deny.
210
+ - command: ["rm#recursive", "remove-item#recursive"]
211
+ args: ["$*"]
212
+ reason: "[deny-rule:rm.recursive-force-dynamic-target] Recursive delete of a substitution-assembled target — verify the variable yourself."
213
+ # The wrapper word xargs is stripped by the decomposer, so pipeline patterns
214
+ # never see it — match the raw command string through the params dimension.
215
+ - params: { command: ["*xargs rm*", "*xargs del*"] }
216
+ reason: "[deny-rule:xargs.rm-recursive-force-dynamic] xargs-fed removals have unverifiable target sets."
217
+ - command: [parallel]
218
+ argv: { pipeline: ["parallel *rm*", "*| parallel*"] }
219
+ reason: "[deny-rule:parallel.rm-recursive-force-dynamic] parallel-fed rm has unresolvable targets."
220
+ - command: [awk]
221
+ args: ["*system(*"]
222
+ reason: "[deny-rule:awk.system-dynamic] awk program text containing system() cannot be analyzed."
223
+ # NOTE: cc's git checkout <ref> <path> ambiguity (G5) is NOT ported — a glob
224
+ # cannot tell ref candidates from paths, and a broad pattern would prompt on
225
+ # every git checkout -b new-branch. Documented residual gap.
226
+
227
+ # Credential reads: ask (A2 ruling kept; egress combos stay in the prompt layer).
228
+ - command: [cat, head, tail, type, "get-content", ls, dir, "get-childitem", "select-string"]
229
+ args: [".env", ".env.*", "*.env", ".npmrc", ".pypirc", ".netrc", ".git-credentials", "id_rsa", "id_ed25519", "id_ecdsa"]
230
+ reason: "[deny-rule:secret.read-credential-file] Reading a credential file requires confirmation. Echo only the keys you need via a filtered command."
231
+ - command: [cat, head, tail, type, "get-content", ls, dir, "get-childitem"]
232
+ args: ["*/.ssh", "*/.ssh/*", "*/.aws", "*/.aws/*", "*/.gnupg", "*/.gnupg/*", "*/.config/gcloud/*", "*/.claude/*", "*/.codex/*"]
233
+ reason: "[deny-rule:secret.read-home-credential-dirs] Browsing ssh/cloud/coding-CLI credential stores requires confirmation."
234
+
235
+ # Self-protection: editing the gate's own rule sources needs confirmation —
236
+ # revocation must be a human act, not something an agent can slide through.
237
+ # (Settings-namespace edits go through the host UI, not a tool call, and are
238
+ # out of reach of this entry; documented gap.)
239
+ - args: ["*rules.yml", "*rules.yaml", "*deny-defaults*"]
240
+ reason: "[deny-rule:gate.self-protection] This call touches the permission gate's own rule sources; changes here alter what the gate enforces."
241
+ `;
242
+ /** Parsed preset document (module-level cache; throws on a malformed table — never silent). */
243
+ let presetDoc;
244
+ export function parsePresetDoc() {
245
+ if (presetDoc === undefined)
246
+ presetDoc = parsePermissionsDocument(PRESET_RULES_YAML);
247
+ return presetDoc;
248
+ }
249
+ /** Every preset id, in table order. Client view + tests use this as the row key. */
250
+ export function presetRuleIds() {
251
+ const doc = parsePresetDoc();
252
+ return [...doc.deny, ...doc.ask].map((e) => presetRuleId(e.reason) ?? '');
253
+ }
254
+ /**
255
+ * A rule entry as it is stored in a raw document (settings JSON / file YAML):
256
+ * only the fields the author wrote — parse fills every dimension with empty
257
+ * arrays, and an empty dimension means "no constraint", so round-tripping the
258
+ * parsed shape verbatim would be noise (still valid, just bloated).
259
+ */
260
+ function rawEntry(e) {
261
+ const out = {};
262
+ if (e.tools.length > 0)
263
+ out.tools = e.tools;
264
+ if (e.command.length > 0)
265
+ out.command = e.command;
266
+ if (e.args.length > 0)
267
+ out.args = e.args;
268
+ if (e.paths.length > 0)
269
+ out.paths = e.paths;
270
+ // `params` 是唯一「源形态 = 映射、编译形态 = ParamCondition[]」的维度(其余维度
271
+ // 两者一致),所以不能直写 e.params:那会产出 `params: [{key,patterns,negated}]`,
272
+ // 下次启动被 parseParamsDimension 拒(`params must be a mapping`)⇒ 插件不激活。
273
+ // 反向映射时把 negation 还原成源头的 `!glob` 前缀写法(parse 就是这么判的)。
274
+ if (e.params.length > 0) {
275
+ const map = {};
276
+ for (const cond of e.params) {
277
+ map[cond.key] = cond.patterns.map((p) => (cond.negated && !p.startsWith('!') ? `!${p}` : p));
278
+ }
279
+ out.params = map;
280
+ }
281
+ if (e.absent.length > 0)
282
+ out.absent = e.absent;
283
+ if (e.agents.length > 0)
284
+ out.agents = e.agents;
285
+ if (e.when !== undefined)
286
+ out.when = e.when;
287
+ if (e.argv !== undefined)
288
+ out.argv = e.argv;
289
+ if (e.network !== undefined)
290
+ out.network = e.network;
291
+ if (e.branch !== undefined)
292
+ out.branch = e.branch;
293
+ out.reason = e.reason;
294
+ out.enabled = e.enabled;
295
+ return out;
296
+ }
297
+ function idsPresentIn(section) {
298
+ const out = new Set();
299
+ if (Array.isArray(section)) {
300
+ for (const item of section) {
301
+ if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
302
+ const id = presetRuleId(item.reason);
303
+ if (id !== undefined)
304
+ out.add(id);
305
+ }
306
+ }
307
+ }
308
+ return out;
309
+ }
310
+ /**
311
+ * Append missing preset entries to a RAW rules document object (bare or
312
+ * `permissions:`-wrapped form). Idempotent and revocation-preserving: an id
313
+ * already present — enabled or not — is never re-added. Mutates nothing;
314
+ * returns the merged copy and the added count.
315
+ */
316
+ export function mergePresetIntoRaw(rawDoc) {
317
+ const root = rawDoc !== null && typeof rawDoc === 'object' && !Array.isArray(rawDoc)
318
+ ? { ...rawDoc }
319
+ : {};
320
+ const wrapper = root['permissions'] !== null && typeof root['permissions'] === 'object' && !Array.isArray(root['permissions'])
321
+ ? root['permissions']
322
+ : root;
323
+ const doc = root['permissions'] !== undefined ? root : { ...wrapper };
324
+ const target = root['permissions'] !== undefined ? root['permissions'] : doc;
325
+ let added = 0;
326
+ const preset = parsePresetDoc();
327
+ for (const [key, list] of [['deny', preset.deny], ['ask', preset.ask]]) {
328
+ const existing = Array.isArray(target[key]) ? [...target[key]] : [];
329
+ const have = idsPresentIn(existing);
330
+ for (const entry of list) {
331
+ const id = presetRuleId(entry.reason);
332
+ if (id !== undefined && have.has(id))
333
+ continue;
334
+ existing.push(rawEntry(entry));
335
+ added += 1;
336
+ }
337
+ target[key] = existing;
338
+ }
339
+ if (typeof doc.defaultAction !== 'string' && typeof target.defaultAction !== 'string') {
340
+ target.defaultAction = 'ask';
341
+ }
342
+ return { doc, added };
343
+ }
344
+ /**
345
+ * One-shot file seed: create the rules file from the preset when absent, or
346
+ * append missing preset entries when present (comments in a hand-written file
347
+ * are lost on rewrite — the file is the plugin's data dir, not user-authored
348
+ * by default). A sibling marker file records that the seed ran: after a full
349
+ * revocation (user deletes every preset entry) the seed must never re-add
350
+ * them, and the marker works on hosts without the settings service where the
351
+ * `presetRulesSeeded` config flag cannot be persisted. Returns what happened
352
+ * for the log line.
353
+ */
354
+ export function ensurePresetRulesFile(rulesFile, markerFile) {
355
+ try {
356
+ if (markerFile !== undefined && existsSync(markerFile))
357
+ return { action: 'already-seeded', added: 0 };
358
+ let result;
359
+ if (!existsSync(rulesFile)) {
360
+ writeFileSync(rulesFile, PRESET_RULES_YAML, 'utf8');
361
+ result = { action: 'created', added: presetRuleIds().length };
362
+ }
363
+ else {
364
+ const text = readFileSync(rulesFile, 'utf8');
365
+ const root = text.trim() === '' ? {} : parse(text);
366
+ const { doc, added } = mergePresetIntoRaw(root);
367
+ if (added === 0) {
368
+ result = { action: 'skipped', added: 0 };
369
+ }
370
+ else {
371
+ writeFileSync(rulesFile, stringify(doc, { lineWidth: 0 }), 'utf8');
372
+ result = { action: 'merged', added };
373
+ }
374
+ }
375
+ if (markerFile !== undefined && result.action !== 'skipped') {
376
+ try {
377
+ writeFileSync(markerFile, new Date().toISOString(), 'utf8');
378
+ }
379
+ catch { /* marker is best-effort */ }
380
+ }
381
+ return result;
382
+ }
383
+ catch {
384
+ // A broken user file must NOT be rewritten by the seed (fail-open on the
385
+ // seed path only: the loader's own badFilePolicy still governs decisions).
386
+ return { action: 'skipped', added: 0 };
387
+ }
388
+ }
package/lib/runtime.js CHANGED
@@ -1068,7 +1068,12 @@ export class PermGateRuntime {
1068
1068
  // user's terminal answer comes back through tools/result anyway.
1069
1069
  if (outcome !== 'ask') {
1070
1070
  this.audit.append(makeEntry({ callId, tool: exec.name, outcome, source, reason: decision.reason, at: Date.now() }));
1071
- this.recordEvent(exec, outcome === 'allow' ? 'auto' : outcome === 'deny' ? 'deny' : 'ask', decision.reason, { verdict: source });
1071
+ // Preset deny-table entries tag their reason with `[deny-rule:<id>]`
1072
+ // (see preset-rules.ts): surface the tag as the verdict so the audit,
1073
+ // the feed and per-rule revocation attribute the exact entry instead of
1074
+ // the whole `rule` partition.
1075
+ const ruleTag = decision.stage === 'rule' ? /\[deny-rule:[a-z0-9._-]+\]/.exec(decision.reason)?.[0] : undefined;
1076
+ this.recordEvent(exec, outcome === 'allow' ? 'auto' : outcome === 'deny' ? 'deny' : 'ask', decision.reason, { verdict: ruleTag ?? source });
1072
1077
  }
1073
1078
  if (decision.action === 'deny')
1074
1079
  return { kind: 'deny', reason: decision.reason };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-perm-gate",
3
- "version": "5.1.2",
3
+ "version": "5.2.0",
4
4
  "publishConfig": {
5
5
  "tag": "latest"
6
6
  },
@@ -26,6 +26,7 @@
26
26
  "CHANGELOG.md",
27
27
  "CHANGELOG.ja.md",
28
28
  "CHANGELOG.ko.md",
29
+ "THIRD_PARTY_NOTICES.md",
29
30
  "LICENSE"
30
31
  ],
31
32
  "main": "lib/index.js",
@@ -62,8 +63,8 @@
62
63
  "./package.json": "./package.json"
63
64
  },
64
65
  "bin": {
65
- "dsh-perm-gate": "./lib/cli.js",
66
- "dsh-perm-gate-patch-glyph": "./scripts/patch-permission-glyph.mjs"
66
+ "dsh-perm-gate": "lib/cli.js",
67
+ "dsh-perm-gate-patch-glyph": "scripts/patch-permission-glyph.mjs"
67
68
  },
68
69
  "engines": {
69
70
  "node": ">=24",