dsh-perm-gate 2.1.0 → 2.6.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 (84) hide show
  1. package/CHANGELOG.ja.md +108 -0
  2. package/CHANGELOG.ko.md +104 -0
  3. package/CHANGELOG.md +347 -0
  4. package/INSTALL.ja.md +84 -8
  5. package/INSTALL.ko.md +80 -9
  6. package/INSTALL.md +83 -8
  7. package/INSTALL.zh.md +70 -7
  8. package/README.ja.md +130 -16
  9. package/README.ko.md +128 -17
  10. package/README.md +179 -25
  11. package/README.zh.md +121 -12
  12. package/assets/allow1.png +0 -0
  13. package/assets/allow2.png +0 -0
  14. package/assets/gateoff.png +0 -0
  15. package/assets/llms.png +0 -0
  16. package/assets/permlist.png +0 -0
  17. package/assets/setting-head.png +0 -0
  18. package/assets/setting-rules.png +0 -0
  19. package/cordis.patch.yml +37 -3
  20. package/docs/baseline-guard.md +56 -0
  21. package/docs/plan-rules-to-settings.md +217 -0
  22. package/docs/rules-format.en.md +294 -0
  23. package/docs/rules-format.md +279 -0
  24. package/docs/ui-gap-analysis-and-branch-permissions.md +338 -0
  25. package/dsh.plugin.json +22 -0
  26. package/examples/permissions.example.yaml +16 -1
  27. package/lib/agent-identity.d.ts +43 -0
  28. package/lib/agent-identity.js +95 -0
  29. package/lib/allowlist.d.ts +27 -0
  30. package/lib/allowlist.js +103 -1
  31. package/lib/cli.js +11 -14
  32. package/lib/client.js +598 -6
  33. package/lib/client.js.map +1 -1
  34. package/lib/command-dispatcher.d.ts +22 -0
  35. package/lib/command-dispatcher.js +97 -0
  36. package/lib/command-semantics.d.ts +62 -0
  37. package/lib/command-semantics.js +6 -0
  38. package/lib/compiler.d.ts +50 -0
  39. package/lib/compiler.js +122 -0
  40. package/lib/config.d.ts +161 -7
  41. package/lib/config.js +78 -11
  42. package/lib/dry-run.d.ts +87 -0
  43. package/lib/dry-run.js +110 -0
  44. package/lib/engine.js +61 -4
  45. package/lib/evaluate.d.ts +9 -0
  46. package/lib/evaluate.js +264 -1
  47. package/lib/events.d.ts +69 -2
  48. package/lib/events.js +123 -0
  49. package/lib/index.d.ts +1 -1
  50. package/lib/index.js +320 -11
  51. package/lib/network-lifecycle.d.ts +107 -0
  52. package/lib/network-lifecycle.js +180 -0
  53. package/lib/network.d.ts +109 -0
  54. package/lib/network.js +196 -0
  55. package/lib/parsers/git.d.ts +43 -0
  56. package/lib/parsers/git.js +404 -0
  57. package/lib/parsers/shell-cmds.d.ts +32 -0
  58. package/lib/parsers/shell-cmds.js +293 -0
  59. package/lib/preset.d.ts +13 -0
  60. package/lib/preset.js +17 -0
  61. package/lib/proxy.d.ts +160 -0
  62. package/lib/proxy.js +529 -0
  63. package/lib/risk.d.ts +17 -3
  64. package/lib/risk.js +19 -6
  65. package/lib/rule-chain.d.ts +39 -0
  66. package/lib/rule-chain.js +213 -0
  67. package/lib/rule-dims.d.ts +174 -0
  68. package/lib/rule-dims.js +230 -0
  69. package/lib/rule.d.ts +60 -0
  70. package/lib/rule.js +69 -7
  71. package/lib/rules-view.d.ts +52 -0
  72. package/lib/rules-view.js +126 -0
  73. package/lib/runtime.d.ts +208 -7
  74. package/lib/runtime.js +433 -27
  75. package/lib/session-sweep.d.ts +45 -0
  76. package/lib/session-sweep.js +153 -0
  77. package/lib/shadow.d.ts +34 -0
  78. package/lib/shadow.js +143 -0
  79. package/lib/shell.d.ts +14 -0
  80. package/lib/shell.js +17 -0
  81. package/lib/watch.d.ts +42 -0
  82. package/lib/watch.js +170 -0
  83. package/package.json +26 -3
  84. package/scripts/patch-permission-glyph.mjs +392 -0
package/lib/runtime.js CHANGED
@@ -11,21 +11,44 @@
11
11
  import { readFileSync } from 'node:fs';
12
12
  import { makeEntry, MemoryAuditMirror } from './audit.js';
13
13
  import { resolvePermissiveStrategies } from './config.js';
14
- import { appendAllowCommand, listAllowCommands, replaceAllowCommands } from './allowlist.js';
14
+ import { appendAllowCommand, listAllowCommands, listAllowFromRulesDoc, replaceAllowCommands } from './allowlist.js';
15
15
  import { decide, CONTENT_ARG_KEYS } from './engine.js';
16
16
  import { EventLog, saveEventSnapshots } from './events.js';
17
17
  import { decideRules } from './evaluate.js';
18
18
  import { canonicalizeCall, GrantRegistry } from './grant.js';
19
19
  import { learnKey, operationFingerprint, RiskLearning } from './learning.js';
20
20
  import { ArtifactRegistry } from './path.js';
21
- import { decomposeShellCommand } from './shell.js';
22
21
  import { classifyRisk, classifyRiskWith } from './risk.js';
23
22
  import { completeViaHost, DEFAULT_HOST_MODEL } from './host-llm.js';
24
23
  import { chatCompletion } from './classifier.js';
25
- import { compileDocument, documentHash, extractPathCandidates, parsePermissionsDocument } from './rule.js';
24
+ import { compileDocument, compileRulesObject, documentHash, extractPathCandidates, parsePermissionsDocument, } from './rule.js';
25
+ import { decomposeShellCommand } from './shell.js';
26
+ import { extractAgentCandidates } from './agent-identity.js';
27
+ import { resolveRuleChain } from './rule-chain.js';
26
28
  import { DEFAULT_DENY_KEYWORDS } from './deny-defaults.js';
27
29
  import { permissionPresetOf, presetInScope } from './preset.js';
28
30
  import { resolveGatePresets } from './config.js';
31
+ /** Locate a rule by its chain-wide index (deny entries first, then allow, then ask). */
32
+ function findRuleByIndex(ruleset, index) {
33
+ return (ruleset.deny.find((entry) => entry.index === index) ??
34
+ ruleset.allow.find((entry) => entry.index === index) ??
35
+ ruleset.ask.find((entry) => entry.index === index));
36
+ }
37
+ /** Which layer of the chain produced a decision, from its stage. */
38
+ function sourceOfStage(stage, reason) {
39
+ switch (stage) {
40
+ case 'hard-deny':
41
+ return 'hard-deny';
42
+ case 'grant':
43
+ return 'grant';
44
+ case 'rule':
45
+ return 'rule';
46
+ case 'classifier':
47
+ return 'classifier';
48
+ default:
49
+ return reason.startsWith('permissive') ? 'permissive' : 'default';
50
+ }
51
+ }
29
52
  /**
30
53
  * One execution's session event log, or `undefined` when the host exposes none.
31
54
  *
@@ -164,6 +187,18 @@ function isCleanupSafe(exec) {
164
187
  }
165
188
  return undefined;
166
189
  }
190
+ /** A shell whose result never arrived is dropped after this long (ms). */
191
+ const IN_FLIGHT_SHELL_TTL_MS = 30 * 60_000;
192
+ /** Tool names that execute a command string (mirrors evaluate's shell roster). */
193
+ const SHELL_TOOL_NAMES = new Set(['shell', 'terminal', 'bash', 'pwsh', 'sh', 'cmd', 'powershell']);
194
+ /** Whether a tool name runs a shell command. */
195
+ function isShellToolName(name) {
196
+ return SHELL_TOOL_NAMES.has(name);
197
+ }
198
+ /** The session-grant key for one network target (scheme/host/port). */
199
+ function networkGrantKey(sessionId, target) {
200
+ return `${sessionId}|${target.scheme ?? ''}|${target.host}|${target.port ?? ''}`;
201
+ }
167
202
  export class PermGateRuntime {
168
203
  options;
169
204
  ruleset;
@@ -177,6 +212,15 @@ export class PermGateRuntime {
177
212
  gatePresets;
178
213
  /** Extra tool names the user classified as safe (config `autoAllowTools`). */
179
214
  autoAllowExtra;
215
+ // ─── Network seam (T2.11) ───────────────────────────────────────────
216
+ /** In-flight shell executions, for proxy-layer network attribution. */
217
+ inFlightShells = new Map();
218
+ /** Stable key per execution object (the host call id is not always present). */
219
+ shellKeys = new WeakMap();
220
+ shellSeq = 0;
221
+ /** Session-scoped network grants: grantKey -> expiry epoch ms. */
222
+ networkGrants = new Map();
223
+ networkGrantTtlMs;
180
224
  /**
181
225
  * Cache of the permission-preset fold. The key is the log's length plus the
182
226
  * identity of its LAST event: a session's log only appends, so "same length
@@ -186,6 +230,12 @@ export class PermGateRuntime {
186
230
  * frozen events, which would otherwise re-fold on every tool call.
187
231
  */
188
232
  presetCache;
233
+ /**
234
+ * Last stand-down announced per session (sessionId → the out-of-scope preset
235
+ * name, `''` when the session records none). Bounded by the session count and
236
+ * only read on the stand-down path — the in-scope path never touches it.
237
+ */
238
+ standDownAnnounced = new Map();
189
239
  learning;
190
240
  events;
191
241
  /** Learning candidates awaiting human approval + execution (call fingerprint → candidate). */
@@ -213,6 +263,7 @@ export class PermGateRuntime {
213
263
  this.gatePresets = options.gatePresets === undefined ? ['*'] : resolveGatePresets(options.gatePresets);
214
264
  this.autoAllowExtra = new Set((options.autoAllowTools ?? []).filter((name) => typeof name === 'string' && name !== ''));
215
265
  this.strategies = resolvePermissiveStrategies(options.permissiveStrategies);
266
+ this.networkGrantTtlMs = options.networkGrantTtlMs ?? 30 * 60_000;
216
267
  this.learning = new RiskLearning(options.learningFile, {
217
268
  threshold: () => this.liveRiskLearning().threshold,
218
269
  now: options.now,
@@ -223,12 +274,51 @@ export class PermGateRuntime {
223
274
  this.ruleset = this.compileInline(options);
224
275
  }
225
276
  compileInline(options) {
226
- const text = options.rulesFile !== undefined ? readFileSafe(options.rulesFile) : '';
227
277
  const empty = {
228
278
  defaultAction: options.defaultAction ?? 'ask',
229
279
  deny: [], allow: [], ask: [],
230
280
  caseInsensitivePaths: options.caseInsensitivePaths ?? true,
231
281
  };
282
+ // Settings-first (dual-source): a configured `dsh-perm-gate-rules`
283
+ // namespace overrides every file path — the gate then loads with zero
284
+ // filesystem dependency. Bare defaults (nothing configured) fall through
285
+ // to the file sources below. Compile errors propagate: the constructor
286
+ // fails loud, and `reload()` keeps the previous rules on a hot change.
287
+ const settingsDoc = options.readRulesDocument?.();
288
+ if (settingsDoc !== undefined) {
289
+ const hash = documentHash(JSON.stringify(settingsDoc));
290
+ const hit = this.cache.get(hash);
291
+ if (hit !== undefined)
292
+ return hit;
293
+ const compiled = compileRulesObject(settingsDoc, {
294
+ maxGlobStars: 2,
295
+ caseInsensitivePaths: options.caseInsensitivePaths ?? true,
296
+ });
297
+ this.cache.set(hash, compiled);
298
+ return compiled;
299
+ }
300
+ // Chain mode: when searchUp is enabled, use the multi-file chain resolver.
301
+ if (options.searchUp === true) {
302
+ const cwd = options.cwd ?? process.cwd();
303
+ const rulesFile = options.rulesFile !== undefined ? options.rulesFile : 'rules.yml';
304
+ try {
305
+ return resolveRuleChain(cwd, {
306
+ rulesFile,
307
+ searchUp: true,
308
+ fallbackPath: options.fallbackPath,
309
+ badFilePolicy: options.badFilePolicy ?? 'fail',
310
+ maxChainLength: options.maxChainLength ?? 10,
311
+ }, {
312
+ maxGlobStars: 2,
313
+ caseInsensitivePaths: options.caseInsensitivePaths ?? true,
314
+ });
315
+ }
316
+ catch {
317
+ return empty;
318
+ }
319
+ }
320
+ // Single-file mode (legacy): read and compile one rules file.
321
+ const text = options.rulesFile !== undefined ? readFileSafe(options.rulesFile) : '';
232
322
  if (text === '')
233
323
  return empty;
234
324
  const hash = documentHash(text);
@@ -307,8 +397,190 @@ export class PermGateRuntime {
307
397
  home: cwdOf(exec),
308
398
  caseInsensitive: this.options.caseInsensitivePaths ?? true,
309
399
  dshHome: this.options.dshHome,
400
+ agentCandidates: extractAgentCandidates(exec),
310
401
  };
311
402
  }
403
+ // ─── Network seam (T2.11) ───────────────────────────────────────────
404
+ /**
405
+ * Register a shell execution as in-flight so a proxy-layer block made by its
406
+ * child process can be attributed back to the session — and therefore ride
407
+ * the interactive approval seam. Called when the gate lets a shell call run;
408
+ * {@link endShellExecution} removes it.
409
+ */
410
+ beginShellExecution(exec) {
411
+ if (!isShellToolName(exec.name))
412
+ return;
413
+ const key = this.shellKeyOf(exec);
414
+ this.inFlightShells.set(key, {
415
+ key,
416
+ tool: exec.name,
417
+ callId: exec.callId,
418
+ agent: exec.agent,
419
+ sessionId: sessionIdOf(exec),
420
+ signal: exec.signal,
421
+ at: Date.now(),
422
+ });
423
+ }
424
+ /** Remove a shell execution from the in-flight table (its tool call settled). */
425
+ endShellExecution(exec) {
426
+ if (!isShellToolName(exec.name))
427
+ return;
428
+ this.inFlightShells.delete(this.shellKeyOf(exec));
429
+ }
430
+ /** Drop every in-flight entry (dispose / session teardown). */
431
+ clearShellExecutions() {
432
+ this.inFlightShells.clear();
433
+ }
434
+ /**
435
+ * The newest live in-flight shell execution, for proxy-layer attribution.
436
+ * Stale entries (a shell that never reported a result) are expired here so a
437
+ * long-dead call can never authorize a later connection.
438
+ */
439
+ currentAttribution() {
440
+ const now = Date.now();
441
+ let newest;
442
+ for (const entry of this.inFlightShells.values()) {
443
+ if (now - entry.at > IN_FLIGHT_SHELL_TTL_MS) {
444
+ this.inFlightShells.delete(entry.key);
445
+ continue;
446
+ }
447
+ // An aborted call was cancelled or rejected: it will never run, so it
448
+ // must not authorize a connection made by some later command.
449
+ if (entry.signal?.aborted === true) {
450
+ this.inFlightShells.delete(entry.key);
451
+ continue;
452
+ }
453
+ if (newest === undefined || entry.at >= newest.at)
454
+ newest = entry;
455
+ }
456
+ if (newest === undefined)
457
+ return undefined;
458
+ return {
459
+ tool: newest.tool,
460
+ ...(newest.callId !== undefined ? { callId: newest.callId } : {}),
461
+ ...(newest.agent !== undefined ? { agent: newest.agent } : {}),
462
+ sessionId: newest.sessionId,
463
+ ...(newest.signal !== undefined ? { signal: newest.signal } : {}),
464
+ };
465
+ }
466
+ /** A stable per-execution key: the host call id, else an object-keyed fallback. */
467
+ shellKeyOf(exec) {
468
+ const cached = this.shellKeys.get(exec);
469
+ if (cached !== undefined)
470
+ return cached;
471
+ const key = typeof exec.callId === 'string' && exec.callId !== ''
472
+ ? `call:${exec.callId}`
473
+ : `exec:${sessionIdOf(exec)}:${exec.name}:${++this.shellSeq}`;
474
+ this.shellKeys.set(exec, key);
475
+ return key;
476
+ }
477
+ /**
478
+ * Ask the human to allow one network target, on behalf of the attributed
479
+ * shell execution. Returns `'allow'` only for an explicit `allowed-once`;
480
+ * every other outcome (rejection, cancellation, timeout, no answerer)
481
+ * fails closed to `'deny'`.
482
+ *
483
+ * The approval seam requires an open turn — the durable audit pair must be
484
+ * enclosed by the log's commit boundary — which holds here because the ask
485
+ * happens *while* the shell tool call is executing.
486
+ */
487
+ async askNetwork(target, reason, timeoutMs) {
488
+ const attribution = this.currentAttribution();
489
+ if (attribution === undefined || attribution.agent === undefined) {
490
+ // Unattributable traffic cannot ride the approval seam — fail closed.
491
+ this.auditNetworkAsk(target, 'deny', 'no in-flight shell to attribute the connection to', undefined);
492
+ return 'deny';
493
+ }
494
+ // A session already allowed this exact target: honour it without asking.
495
+ const grantKey = networkGrantKey(attribution.sessionId, target);
496
+ if (this.networkGrants.has(grantKey)) {
497
+ return 'allow';
498
+ }
499
+ const requester = this.options.requestApproval;
500
+ if (requester === undefined) {
501
+ this.auditNetworkAsk(target, 'deny', 'no approval service available', attribution);
502
+ return 'deny';
503
+ }
504
+ const controller = new AbortController();
505
+ let timer;
506
+ // The timeout is raced, not merely signalled. `abort()` only *asks* the
507
+ // callee to withdraw the question; a callee that ignores the signal would
508
+ // otherwise leave this await pending forever and hang the CONNECT. The
509
+ // race guarantees the wait settles no matter what the seam does.
510
+ const timedOut = new Promise((resolve) => {
511
+ timer = setTimeout(() => {
512
+ controller.abort();
513
+ resolve('unavailable');
514
+ }, Math.max(1, timeoutMs));
515
+ });
516
+ let outcome = 'unavailable';
517
+ try {
518
+ outcome = await Promise.race([
519
+ requester({
520
+ agent: attribution.agent,
521
+ toolName: attribution.tool,
522
+ ...(attribution.callId !== undefined ? { callId: attribution.callId } : {}),
523
+ reason,
524
+ signal: controller.signal,
525
+ }),
526
+ timedOut,
527
+ ]);
528
+ }
529
+ catch {
530
+ // A seam failure (e.g. no open turn) is never an allow.
531
+ outcome = 'unavailable';
532
+ }
533
+ finally {
534
+ if (timer !== undefined)
535
+ clearTimeout(timer);
536
+ }
537
+ if (outcome === 'allowed-once') {
538
+ // Remember it for this session so one command making many connections
539
+ // does not prompt once per connection.
540
+ this.networkGrants.set(grantKey, Date.now() + this.networkGrantTtlMs);
541
+ this.auditNetworkAsk(target, 'allow', 'approved by the human (session grant)', attribution);
542
+ return 'allow';
543
+ }
544
+ this.auditNetworkAsk(target, 'deny', `approval seam returned ${outcome}`, attribution);
545
+ return 'deny';
546
+ }
547
+ /** Record one network escalation outcome in the audit mirror + event feed. */
548
+ auditNetworkAsk(target, verdict, reason, attribution) {
549
+ const now = Date.now();
550
+ const tool = attribution?.tool ?? 'subprocess';
551
+ const detail = `network ${verdict === 'allow' ? 'allowed' : 'denied'} ${target.scheme ?? '?'}://${target.host}${target.port !== undefined ? `:${target.port}` : ''} — ${reason}`;
552
+ try {
553
+ this.audit.append(makeEntry({
554
+ callId: randomId(),
555
+ tool,
556
+ outcome: verdict === 'allow' ? 'allow' : 'deny',
557
+ source: 'rule',
558
+ reason: detail,
559
+ at: now,
560
+ }));
561
+ }
562
+ catch {
563
+ // Auditing must never influence the verdict.
564
+ }
565
+ if (this.events !== undefined && attribution?.sessionId !== undefined) {
566
+ try {
567
+ this.events.append({
568
+ sessionId: attribution.sessionId,
569
+ tool,
570
+ kind: verdict === 'allow' ? 'auto' : 'deny',
571
+ reason: detail,
572
+ verdict: 'network-ask',
573
+ });
574
+ }
575
+ catch {
576
+ // Same: the feed is best-effort.
577
+ }
578
+ }
579
+ }
580
+ /** The compiled ruleset (read-only view for network module). */
581
+ get compiledRuleset() {
582
+ return this.ruleset;
583
+ }
312
584
  liveRiskLearning() {
313
585
  const read = this.options.readRiskLearning;
314
586
  if (read !== undefined)
@@ -362,9 +634,38 @@ export class PermGateRuntime {
362
634
  * `danger-full-access` means "full access without approval prompts", where a
363
635
  * forwarded ask can only fail (the approval seam rejects before any answerer
364
636
  * runs) and a hard-deny would silently overrule the tier the user chose.
637
+ *
638
+ * P0 is therefore monotonic only WITHIN the gate's scope, not across every
639
+ * preset. {@link announceStandDown} makes that visible instead of silent.
365
640
  */
366
- gateActive(exec) {
367
- return presetInScope(this.presetOf(exec), this.gatePresets);
641
+ gateActive(preset) {
642
+ return presetInScope(preset, this.gatePresets);
643
+ }
644
+ /**
645
+ * Record one `stand-down` event the first time a session is observed with a
646
+ * preset outside {@link gatePresets}, and again whenever that preset changes.
647
+ *
648
+ * Per-call silence is what makes a stand-down dangerous: the tool call looks
649
+ * exactly like a call the gate inspected and allowed. One event per
650
+ * transition is enough to say otherwise, and keeps the feed (and the
651
+ * `events.jsonl` append) proportional to preset changes rather than to call
652
+ * volume.
653
+ *
654
+ * The event is a NOTICE, not a decision: it carries no allow/deny meaning,
655
+ * and the call it was observed on is still settled entirely by the selected
656
+ * tier. `verdict: 'stand-down'` labels it for the history view.
657
+ */
658
+ announceStandDown(exec, preset) {
659
+ const sessionId = sessionIdOf(exec);
660
+ const announced = preset ?? '';
661
+ if (this.standDownAnnounced.get(sessionId) === announced)
662
+ return;
663
+ this.standDownAnnounced.set(sessionId, announced);
664
+ const scope = this.gatePresets.includes('*') ? 'every preset' : this.gatePresets.join(', ');
665
+ const named = preset === undefined ? 'no permission/preset recorded' : `"${preset}"`;
666
+ this.recordEvent(exec, 'stand-down', `perm-gate is INACTIVE: the session permission preset is ${named} and the gate scope is ${scope}. `
667
+ + 'No P0 hard-deny, no rule, no ask and no allow is evaluated for any call in this session — '
668
+ + 'the selected tier\'s own policy governs. Select a preset in the gate scope to re-arm it.', { verdict: 'stand-down' });
368
669
  }
369
670
  /**
370
671
  * Whether an `ask` produced while the gate is active can actually reach a
@@ -581,10 +882,16 @@ export class PermGateRuntime {
581
882
  */
582
883
  decideExecution(exec) {
583
884
  // The gate is scoped to its own tier(s): anywhere else it stands down
584
- // entirely and records nothing (the session's selected tier owns the call).
585
- if (!this.gateActive(exec))
885
+ // entirely and decides nothing (the session's selected tier owns the call).
886
+ // The stand-down is announced ONCE per (session, preset) transition — the
887
+ // call's outcome is unchanged, but the user must be able to see that the
888
+ // gate — including P0 hard-deny — is inactive; a silent stand-down reads as
889
+ // "the gate looked at this and allowed it".
890
+ const preset = this.presetOf(exec);
891
+ if (!this.gateActive(preset)) {
892
+ this.announceStandDown(exec, preset);
586
893
  return undefined;
587
- const ctx = this.ctxFor(exec);
894
+ }
588
895
  const callId = randomId();
589
896
  // Preset deny-keyword layer (deny wins over allow): a dangerous-keyword hit
590
897
  // vetoes before grants / rules / LLM. Additive deny only — P0 semantics are
@@ -596,13 +903,9 @@ export class PermGateRuntime {
596
903
  this.recordEvent(exec, 'deny', reason, { verdict: 'deny-keyword' });
597
904
  return { kind: 'deny', reason };
598
905
  }
599
- const grantResolver = (tool, args) => {
600
- if (exec.parentAuthorized === false)
601
- return 'no-match';
602
- return this.grants.decide(tool, args);
603
- };
604
- const raw = decide(ctx, { decide: (c) => decideRules(this.ruleset, c) }, grantResolver, this.autoAllowExtra);
605
- const decision = this.applyPermissive(raw);
906
+ // The same chain `explainCall` reports, evaluated once here so the two views
907
+ // cannot drift; this caller adds the side effects and the session layers.
908
+ const decision = this.applyPermissive(this.rawPolicy(exec));
606
909
  // Deterministic cleanup pre-screen: if a deletion command targets a
607
910
  // regenerable/temporary artifact inside the workspace, allow it directly.
608
911
  // This runs BEFORE the ask/passthrough logic so it works even under
@@ -671,6 +974,83 @@ export class PermGateRuntime {
671
974
  this.clearCall(exec, decision.reason, source, source);
672
975
  return undefined;
673
976
  }
977
+ /**
978
+ * The **pure policy** outcome for one call: the same chain
979
+ * {@link decideExecution} runs, with every side effect and every session-state
980
+ * layer removed.
981
+ *
982
+ * Side effects removed — no audit entry, no event, no ask tracking, no call
983
+ * clearance, no learning. A rule-test panel must be able to run on every
984
+ * keystroke without writing to the live decision feed.
985
+ *
986
+ * Session-state layers removed — the preset stand-down and the
987
+ * `approval: never` ask degradation both describe a session a session-less
988
+ * caller does not have. Including them is not harmless: the degradation turns
989
+ * every "the rules would ask a human" into a reported **allow**, which is the
990
+ * opposite of the truth for exactly the rules someone opens the panel to
991
+ * review (measured on the live host: `shell ls -la` reported `allow` while the
992
+ * rule layer said `ask`).
993
+ *
994
+ * `reason` is never collapsed: "allow" has causes worth naming (a rule, a
995
+ * grant, cleanup-safety, the permissive default).
996
+ */
997
+ explainCall(exec) {
998
+ const keyword = this.denyKeywordHit(exec);
999
+ if (keyword !== undefined) {
1000
+ return {
1001
+ action: 'deny',
1002
+ reason: `deny-keyword: matches preset blacklist entry "${keyword}"`,
1003
+ source: 'deny-keyword',
1004
+ };
1005
+ }
1006
+ const decision = this.applyPermissive(this.rawPolicy(exec));
1007
+ if (decision.action === 'ask') {
1008
+ const cleanupSafe = isCleanupSafe(exec);
1009
+ if (cleanupSafe !== undefined) {
1010
+ return { action: 'allow', reason: `cleanup-safe: ${cleanupSafe}`, source: 'cleanup-safe' };
1011
+ }
1012
+ }
1013
+ return {
1014
+ action: decision.action,
1015
+ reason: decision.reason,
1016
+ source: sourceOfStage(decision.stage, decision.reason),
1017
+ ...(decision.ruleIndex !== undefined ? { ruleIndex: decision.ruleIndex } : {}),
1018
+ };
1019
+ }
1020
+ /**
1021
+ * P0 hard-deny → P1 session grant → P2 rules → P4 ask, as a value. Shared by
1022
+ * the host-facing decision and {@link explainCall} so the two cannot drift.
1023
+ */
1024
+ rawPolicy(exec) {
1025
+ const grantResolver = (tool, args) => {
1026
+ if (exec.parentAuthorized === false)
1027
+ return 'no-match';
1028
+ return this.grants.decide(tool, args);
1029
+ };
1030
+ return decide(this.ctxFor(exec), { decide: (c) => decideRules(this.ruleset, c) }, grantResolver, this.autoAllowExtra);
1031
+ }
1032
+ /**
1033
+ * Rule-layer explanation for one would-be call: what the `permissions` chain
1034
+ * decides on its own, before P0 hard-deny, P1 session grants, the P3
1035
+ * classifier and the P4 ask ever get a say.
1036
+ *
1037
+ * This is the read-only half of a dry-run. It appends nothing to the audit
1038
+ * mirror, records no event, mints no grant and touches no learning store, so a
1039
+ * "rule test" panel may call it on every keystroke without leaving a trace in
1040
+ * the decision feed. It answers a narrower question than
1041
+ * {@link decideExecution} and says so: `ruleIndex` identifies a rule in the
1042
+ * chain, which only this layer can attribute.
1043
+ */
1044
+ explainRules(exec) {
1045
+ const decision = decideRules(this.ruleset, this.ctxFor(exec));
1046
+ return {
1047
+ action: decision.action,
1048
+ reason: decision.reason,
1049
+ ruleIndex: decision.ruleIndex,
1050
+ rule: decision.ruleIndex === undefined ? undefined : findRuleByIndex(this.ruleset, decision.ruleIndex),
1051
+ defaultAction: this.ruleset.defaultAction,
1052
+ };
1053
+ }
674
1054
  /**
675
1055
  * Apply the Permissive tier to a raw decision. Hard-deny and minted-grant
676
1056
  * outcomes are never touched (fail-closed). `alwaysConfirm` escalates a
@@ -777,13 +1157,22 @@ export class PermGateRuntime {
777
1157
  if (risk.kind === 'risky') {
778
1158
  if (risk.category !== 'neutral') {
779
1159
  // Hard category (deletion / credential / remote / system / bulk) or an
780
- // off-protocol output graded hard: auto-deny. The operation is clearly
781
- // dangerous or sensitive — no popup, no learning, just block.
782
- const reason = `${decision.reason} [llm-assist risky:${risk.category} → auto-deny]`;
783
- this.audit.append(makeEntry({ callId: randomId(), tool: exec.name, outcome: 'deny', source: 'classifier', reason, at: Date.now() }));
784
- this.recordEvent(exec, 'deny', reason, { risk: risk.category, verdict: 'llm-deny', category: risk.category });
785
- this.untrackAsk(exec);
786
- return { kind: 'deny', reason };
1160
+ // off-protocol output graded hard: KEEP THE ASK.
1161
+ //
1162
+ // Denying here would make a probabilistic verdict the source of an
1163
+ // unappealable block: the gate would be "sure enough" to refuse on the
1164
+ // model's word alone, with no popup to appeal to and no grant to retry
1165
+ // with — measured live on a benign `git commit -F …` the grader called
1166
+ // `remote`. Deny stays with the deterministic layers (P0 hard-deny, the
1167
+ // deny-keyword blacklist, explicit `deny:` rules); everything the
1168
+ // classifier merely *suspects* is negotiated with the human instead.
1169
+ //
1170
+ // Hard categories stay distinct from `neutral` in one way that matters:
1171
+ // they are NEVER learnable, so repeated confirmations cannot sediment
1172
+ // them into an auto-allow.
1173
+ const hardReason = `${decision.reason} [llm-assist risky:${risk.category} → ask]`;
1174
+ this.trackAsk(exec, hardReason, undefined);
1175
+ return { kind: 'ask', reason: hardReason };
787
1176
  }
788
1177
  const learning = this.liveRiskLearning();
789
1178
  const fp = operationFingerprint(exec.name, exec.arguments ?? {}, exec.commandText);
@@ -1022,27 +1411,44 @@ export class PermGateRuntime {
1022
1411
  }
1023
1412
  /**
1024
1413
  * Grant "allow every occurrence of this command" (the always-confirm panel's
1025
- * second extended allow button): persist the command into the rules file's
1026
- * `allow` whitelist and reload. Returns the reload result.
1414
+ * second extended allow button): persist the command into the rules' `allow`
1415
+ * whitelist and reload. Prefers the settings-backed writer (the namespace's
1416
+ * watch triggers the reload once the write lands); falls back to the rules
1417
+ * file when no settings scope is wired. Returns the write verdict.
1027
1418
  */
1028
1419
  approveAllowEverywhere(commandWord, reason = 'permissive allow-everywhere') {
1420
+ const writer = this.options.allowlistWriter;
1421
+ if (writer !== undefined && writer.append(commandWord, reason))
1422
+ return true;
1029
1423
  if (!this.options.rulesFile)
1030
1424
  return false;
1031
1425
  if (!appendAllowCommand(this.options.rulesFile, commandWord, reason))
1032
1426
  return false;
1033
1427
  return this.reload();
1034
1428
  }
1035
- /** Read-only view of the current allow-list command patterns (whitelist). */
1429
+ /**
1430
+ * Read-only view of the current allow-list command patterns (whitelist).
1431
+ * Reads the settings document when the namespace is configured, else the
1432
+ * rules file.
1433
+ */
1036
1434
  allowlist() {
1435
+ const doc = this.options.readRulesDocument?.();
1436
+ if (doc !== undefined)
1437
+ return listAllowFromRulesDoc(doc);
1037
1438
  if (!this.options.rulesFile)
1038
1439
  return [];
1039
1440
  return listAllowCommands(this.options.rulesFile);
1040
1441
  }
1041
1442
  /**
1042
1443
  * Replace the whitelist with exactly the given command patterns and reload.
1043
- * Returns the reload result (false when no rulesFile or the write failed).
1444
+ * Prefers the settings-backed writer; falls back to the rules file when no
1445
+ * settings scope is wired. Returns the write verdict (false when neither
1446
+ * sink is available or the write failed).
1044
1447
  */
1045
1448
  setAllowlist(patterns) {
1449
+ const writer = this.options.allowlistWriter;
1450
+ if (writer !== undefined && writer.replace(patterns, 'permissive allowlist'))
1451
+ return true;
1046
1452
  if (!this.options.rulesFile)
1047
1453
  return false;
1048
1454
  if (!replaceAllowCommands(this.options.rulesFile, patterns))
@@ -0,0 +1,45 @@
1
+ /** Session ids grouped by their lifecycle class, all prefix-stripped (see {@link normalizeSessionId}). */
2
+ export interface SessionClassification {
3
+ /** Sessions referenced by some workspace's `sessionIds`. */
4
+ readonly live: ReadonlySet<string>;
5
+ /** Sessions listed in the store's `global.archivedSessionIds`. */
6
+ readonly archived: ReadonlySet<string>;
7
+ }
8
+ /** A session id whose gate data is stale and can be swept. */
9
+ export interface SweepReport {
10
+ /** Decision-event lines removed from events.jsonl. */
11
+ readonly eventsRemoved: number;
12
+ /** Snapshot files deleted from the snapshots directory. */
13
+ readonly snapshotsRemoved: number;
14
+ }
15
+ /** Strip the `session-` prefix so ids from different sources compare equal. */
16
+ export declare function normalizeSessionId(id: unknown): string;
17
+ /**
18
+ * Classify sessions from the raw text of the workspace store. Returns `null`
19
+ * on unparsable or structurally unexpected content (torn write, format bump) —
20
+ * the caller skips the round. Missing `global`/`tables` sections degrade to
21
+ * empty sets rather than failing: a fresh install has neither.
22
+ */
23
+ export declare function classifySessions(raw: string): SessionClassification | null;
24
+ /**
25
+ * Whether a gate-side session id belongs to an archived or deleted session.
26
+ * An empty id (unattributable legacy row) never matches — the same semantics
27
+ * `snapshotMatchesSession` uses for snapshots, so unattributable data is kept.
28
+ */
29
+ export declare function isStaleSession(id: string, cls: SessionClassification): boolean;
30
+ /** Options for {@link sweepSessionData}; all paths are plugin-owned except the store. */
31
+ export interface SweepOptions {
32
+ readonly classification: SessionClassification;
33
+ /** The decision-event JSONL feed (plugin data dir). */
34
+ readonly eventsFile: string;
35
+ /** The per-event pre-change snapshot directory (plugin data dir). */
36
+ readonly snapshotsDir: string;
37
+ }
38
+ /**
39
+ * Drop archived/dead sessions' rows from `events.jsonl` (atomic tmp+rename
40
+ * rewrite, only when something is actually removed) and delete their snapshot
41
+ * files. Both halves are independent: a failure in one still lets the other
42
+ * run. I/O errors are swallowed with a warn — best-effort cleanup, never a
43
+ * gating concern.
44
+ */
45
+ export declare function sweepSessionData(opts: SweepOptions): SweepReport;