dsh-perm-gate 2.1.2 → 2.6.2

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 (67) hide show
  1. package/CHANGELOG.ja.md +108 -0
  2. package/CHANGELOG.ko.md +104 -0
  3. package/CHANGELOG.md +239 -0
  4. package/INSTALL.ja.md +41 -21
  5. package/INSTALL.ko.md +37 -19
  6. package/INSTALL.md +46 -23
  7. package/INSTALL.zh.md +33 -17
  8. package/README.ja.md +52 -12
  9. package/README.ko.md +53 -13
  10. package/README.md +68 -10
  11. package/README.zh.md +41 -10
  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 +11 -8
  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 +2 -2
  26. package/examples/permissions.example.yaml +16 -1
  27. package/lib/allowlist.d.ts +27 -0
  28. package/lib/allowlist.js +103 -1
  29. package/lib/cli.js +11 -14
  30. package/lib/client.js +484 -6
  31. package/lib/client.js.map +1 -1
  32. package/lib/command-dispatcher.d.ts +22 -0
  33. package/lib/command-dispatcher.js +97 -0
  34. package/lib/command-semantics.d.ts +62 -0
  35. package/lib/command-semantics.js +6 -0
  36. package/lib/compiler.d.ts +11 -0
  37. package/lib/compiler.js +16 -0
  38. package/lib/config.d.ts +73 -12
  39. package/lib/config.js +56 -14
  40. package/lib/dry-run.d.ts +87 -0
  41. package/lib/dry-run.js +110 -0
  42. package/lib/engine.js +52 -2
  43. package/lib/evaluate.js +76 -2
  44. package/lib/events.d.ts +56 -2
  45. package/lib/events.js +92 -0
  46. package/lib/index.js +106 -26
  47. package/lib/parsers/git.d.ts +43 -0
  48. package/lib/parsers/git.js +404 -0
  49. package/lib/parsers/shell-cmds.d.ts +32 -0
  50. package/lib/parsers/shell-cmds.js +293 -0
  51. package/lib/preset.d.ts +13 -0
  52. package/lib/preset.js +17 -0
  53. package/lib/risk.d.ts +17 -3
  54. package/lib/risk.js +17 -3
  55. package/lib/rule-chain.js +13 -22
  56. package/lib/rule-dims.d.ts +35 -0
  57. package/lib/rule-dims.js +36 -0
  58. package/lib/rule.d.ts +36 -1
  59. package/lib/rule.js +56 -8
  60. package/lib/rules-view.d.ts +52 -0
  61. package/lib/rules-view.js +126 -0
  62. package/lib/runtime.d.ts +121 -7
  63. package/lib/runtime.js +204 -25
  64. package/lib/shell.d.ts +14 -0
  65. package/lib/shell.js +17 -0
  66. package/package.json +19 -2
  67. package/scripts/patch-permission-glyph.mjs +225 -42
package/lib/rule.js CHANGED
@@ -9,8 +9,8 @@
9
9
  * nothing matches, `defaultAction` applies.
10
10
  */
11
11
  import { parse } from 'yaml';
12
- import { compileGlob, hashText } from './compiler.js';
13
- import { parseParamsDimension, parseAbsentDimension, parseAgentsDimension, parseWhenDimension, parseArgvDimension, parseNetworkDimension, } from './rule-dims.js';
12
+ import { compileGlob, compileBranchPattern, hashText } from './compiler.js';
13
+ import { parseParamsDimension, parseAbsentDimension, parseAgentsDimension, parseWhenDimension, parseArgvDimension, parseNetworkDimension, parseBranchDimension, } from './rule-dims.js';
14
14
  const VALID_ACTIONS = ['allow', 'ask', 'deny'];
15
15
  const PATH_CANDIDATE_KEYS = [
16
16
  'path', 'paths', 'file', 'files', 'file_path', 'dir', 'directory', 'directories',
@@ -54,6 +54,17 @@ export function parsePermissionsDocument(text) {
54
54
  catch (error) {
55
55
  throw new RuleError(`invalid YAML: ${String(error)}`);
56
56
  }
57
+ return parsePermissionsObject(raw);
58
+ }
59
+ /**
60
+ * Parse a permissions document given as an already-parsed object — the shape
61
+ * the DSH settings namespace stores (the JSON twin of the YAML file). Accepts
62
+ * both the bare `{ defaultAction, deny, allow, ask }` form and the file's
63
+ * `permissions:`-wrapped form. Malformed input fails loud, exactly like the
64
+ * YAML path, so a settings doc that cannot compile is a state the operator
65
+ * sees, never a silent ruleset change.
66
+ */
67
+ export function parsePermissionsObject(raw) {
57
68
  if (raw === null || raw === undefined) {
58
69
  return { defaultAction: 'ask', deny: [], allow: [], ask: [] };
59
70
  }
@@ -83,7 +94,7 @@ export function parsePermissionsDocument(text) {
83
94
  function parseRuleEntry(raw, action, at) {
84
95
  if (!isRecord(raw))
85
96
  throw new RuleError(`${at} must be a mapping`);
86
- const VALID_KEYS = ['tools', 'command', 'args', 'paths', 'params', 'absent', 'agents', 'when', 'argv', 'network', 'action', 'reason', 'enabled'];
97
+ const VALID_KEYS = ['tools', 'command', 'args', 'paths', 'params', 'absent', 'agents', 'when', 'argv', 'network', 'branch', 'action', 'reason', 'enabled'];
87
98
  const unknown = Object.keys(raw).filter((k) => !VALID_KEYS.includes(k));
88
99
  if (unknown.length > 0) {
89
100
  throw new RuleError(`${at} unknown field${unknown.length > 1 ? 's' : ''} ${unknown.map((k) => JSON.stringify(k)).join(', ')}`);
@@ -106,6 +117,7 @@ function parseRuleEntry(raw, action, at) {
106
117
  when: parseWhenDimension(raw.when, `${at}.when`),
107
118
  argv: parseArgvDimension(raw.argv, `${at}.argv`),
108
119
  network: parseNetworkDimension(raw.network, `${at}.network`),
120
+ branch: parseBranchDimension(raw.branch, `${at}.branch`),
109
121
  action,
110
122
  reason: reason === undefined ? `${action}` : reason,
111
123
  enabled: raw.enabled === undefined ? true : typeof raw.enabled === 'boolean' ? raw.enabled : (() => { throw new RuleError(`${at}.enabled must be a boolean`); })(),
@@ -141,10 +153,32 @@ function compileCommand(entry, opts) {
141
153
  }
142
154
  return { word: compileGlob(word, { segments: false, maxStars: opts.maxGlobStars ?? 2 }), flag };
143
155
  }
144
- /** Compile a validated document into hot-path rules. */
145
- export function compileDocument(doc, opts = {}) {
146
- const comp = (list, action, offset) => list.map((entry, i) => ({
147
- index: offset + i,
156
+ /**
157
+ * Compile the `branch` dimension (undefined = the rule does not constrain on it).
158
+ *
159
+ * Glob degree is bounded inside `compileBranchPattern` (DEFAULT_MAX_STARS), so
160
+ * this takes no options: branch patterns are never path-segmented.
161
+ */
162
+ function compileBranchDimension(dim) {
163
+ if (dim === undefined)
164
+ return undefined;
165
+ return {
166
+ target: (dim.target ?? []).map((p) => compileBranchPattern(p)),
167
+ remote: (dim.remote ?? []).map((p) => compileBranchPattern(p)),
168
+ shared: dim.shared === true,
169
+ };
170
+ }
171
+ /**
172
+ * Compile one parsed entry into a hot-path rule.
173
+ *
174
+ * Exported because the multi-file rule chain (`rule-chain.ts`) merges entries
175
+ * from several documents into one ruleset and must produce the SAME shape here
176
+ * — an entry built by hand would silently drop every compiled dimension, and an
177
+ * empty dimension means "no constraint", i.e. "matches everything".
178
+ */
179
+ export function compileRuleEntry(entry, action, index, opts = {}) {
180
+ return {
181
+ index,
148
182
  action,
149
183
  reason: entry.reason,
150
184
  enabled: entry.enabled,
@@ -158,8 +192,13 @@ export function compileDocument(doc, opts = {}) {
158
192
  when: entry.when,
159
193
  argv: entry.argv,
160
194
  network: entry.network,
195
+ branch: compileBranchDimension(entry.branch),
161
196
  source: entry,
162
- }));
197
+ };
198
+ }
199
+ /** Compile a validated document into hot-path rules. */
200
+ export function compileDocument(doc, opts = {}) {
201
+ const comp = (list, action, offset) => list.map((entry, i) => compileRuleEntry(entry, action, offset + i, opts));
163
202
  return {
164
203
  defaultAction: doc.defaultAction,
165
204
  deny: comp(doc.deny, 'deny', 0),
@@ -168,6 +207,15 @@ export function compileDocument(doc, opts = {}) {
168
207
  caseInsensitivePaths: opts.caseInsensitivePaths ?? false,
169
208
  };
170
209
  }
210
+ /**
211
+ * Compile rules given as a structured settings object (already parsed — no
212
+ * YAML in the loop). The settings-first half of the dual-source read path:
213
+ * `parsePermissionsObject` validates the JSON form exactly as strictly as the
214
+ * YAML path, then the standard `compileDocument` runs.
215
+ */
216
+ export function compileRulesObject(root, opts = {}) {
217
+ return compileDocument(parsePermissionsObject(root), opts);
218
+ }
171
219
  /** SHA-256 hash of the raw document (compile-cache key without recompiling). */
172
220
  export function documentHash(text) {
173
221
  return hashText(text);
@@ -0,0 +1,52 @@
1
+ import { type RuleAction } from './rule.js';
2
+ /** Rules per action, so the panel can say "3 deny / 7 allow / 1 ask" at a glance. */
3
+ export interface RulesViewCounts {
4
+ readonly allow: number;
5
+ readonly deny: number;
6
+ readonly ask: number;
7
+ }
8
+ /** The whole read-only face of one permissions document. */
9
+ export interface RulesView {
10
+ /** Absolute path, or `''` when the gate has no rules file configured. */
11
+ readonly path: string;
12
+ readonly exists: boolean;
13
+ /** File size on disk, not the length of `raw` — a truncated view still reports the truth. */
14
+ readonly bytes: number;
15
+ /** Content hash of the text the gate would load, for correlating with a reload. */
16
+ readonly hash: string;
17
+ readonly lines: number;
18
+ /** The document itself. Empty when absent, unreadable, or truncated away. */
19
+ readonly raw: string;
20
+ readonly truncated: boolean;
21
+ /** Parsed only when the document compiled; absent when it did not. */
22
+ readonly defaultAction?: RuleAction;
23
+ readonly counts: RulesViewCounts;
24
+ /** Why the document could not be shown or parsed. Absent when all is well. */
25
+ readonly error?: string;
26
+ /** Where the rules were read from. Absent on views produced before the settings migration. */
27
+ readonly source?: 'file' | 'settings';
28
+ /** The structured rules object the settings namespace stores (settings source only). */
29
+ readonly rules?: unknown;
30
+ }
31
+ /**
32
+ * Read one permissions document for display.
33
+ *
34
+ * Never throws: a missing, unreadable or malformed file is a state the panel has
35
+ * to render, not an error that should blank the section. Every failure mode is
36
+ * reported in the returned value instead.
37
+ */
38
+ export declare function readRulesView(rulesFile: string): RulesView;
39
+ /** The pseudo-path a settings view reports (there is no file to name). */
40
+ export declare const SETTINGS_RULES_VIEW_PATH = "settings:dsh-perm-gate-rules";
41
+ /**
42
+ * Render the settings-sourced rules for display — the read-only face of the
43
+ * `dsh-perm-gate-rules` namespace, the twin of {@link readRulesView}.
44
+ *
45
+ * The YAML rendering of the structured object fills `raw`, so the settings card
46
+ * shows the document in the same format the file view used; `rules` carries the
47
+ * structured object itself and `source: 'settings'` states the provenance.
48
+ *
49
+ * Never throws: a doc that fails `compileRulesObject` is a state the panel has
50
+ * to render (the `error` field), not an error that should blank the section.
51
+ */
52
+ export declare function readRulesViewFromSettings(rules: unknown): RulesView;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Reading the permissions YAML, for the panel that shows it.
3
+ *
4
+ * The card could already WRITE the rules file — the allowlist section appends
5
+ * and replaces patterns, and `index.ts` mirrors those edits back into the
6
+ * document. Nothing could read it back, so an operator editing patterns through
7
+ * the panel had no way to see the document they were editing: the file the gate
8
+ * actually loads was invisible from the UI.
9
+ *
10
+ * This is the missing read half. It is deliberately **read-only** — a view must
11
+ * never be able to change the ruleset it is describing, or "let me look at the
12
+ * rules" becomes a risky action. Writing stays where it already lives
13
+ * (`allowlist.ts`, the settings namespace).
14
+ */
15
+ import { readFileSync, statSync } from 'node:fs';
16
+ import { stringify } from 'yaml';
17
+ import { RuleError, compileDocument, compileRulesObject, documentHash, parsePermissionsDocument } from './rule.js';
18
+ /** Beyond this the panel would be rendering a log, not a rules file. */
19
+ const MAX_VIEW_BYTES = 512 * 1024;
20
+ const NO_COUNTS = { allow: 0, deny: 0, ask: 0 };
21
+ /**
22
+ * Read one permissions document for display.
23
+ *
24
+ * Never throws: a missing, unreadable or malformed file is a state the panel has
25
+ * to render, not an error that should blank the section. Every failure mode is
26
+ * reported in the returned value instead.
27
+ */
28
+ export function readRulesView(rulesFile) {
29
+ if (rulesFile === '') {
30
+ return {
31
+ path: '', exists: false, bytes: 0, hash: '', lines: 0, raw: '', truncated: false,
32
+ counts: NO_COUNTS, error: 'no rules file is configured',
33
+ };
34
+ }
35
+ let bytes = 0;
36
+ let exists = true;
37
+ try {
38
+ bytes = statSync(rulesFile).size;
39
+ }
40
+ catch {
41
+ exists = false;
42
+ }
43
+ let raw = '';
44
+ try {
45
+ raw = readFileSync(rulesFile, 'utf8');
46
+ }
47
+ catch (e) {
48
+ return {
49
+ path: rulesFile, exists, bytes, hash: '', lines: 0, raw: '', truncated: false,
50
+ counts: NO_COUNTS,
51
+ error: exists ? `unreadable: ${String(e?.message ?? e)}` : 'file does not exist',
52
+ };
53
+ }
54
+ const lines = raw === '' ? 0 : raw.split('\n').length;
55
+ const truncated = raw.length > MAX_VIEW_BYTES;
56
+ const shown = truncated ? raw.slice(0, MAX_VIEW_BYTES) : raw;
57
+ // Parsing is what makes the view worth having: a YAML file that loads as text
58
+ // but not as rules is exactly the failure the panel exists to expose.
59
+ try {
60
+ const doc = parsePermissionsDocument(raw);
61
+ const ruleset = compileDocument(doc);
62
+ return {
63
+ path: rulesFile, exists: true, bytes, hash: documentHash(raw), lines,
64
+ raw: shown, truncated,
65
+ defaultAction: ruleset.defaultAction,
66
+ counts: { allow: ruleset.allow.length, deny: ruleset.deny.length, ask: ruleset.ask.length },
67
+ source: 'file',
68
+ };
69
+ }
70
+ catch (e) {
71
+ const detail = e instanceof RuleError ? e.message : String(e?.message ?? e);
72
+ return {
73
+ path: rulesFile, exists: true, bytes, hash: documentHash(raw), lines,
74
+ raw: shown, truncated,
75
+ counts: NO_COUNTS,
76
+ error: `does not compile: ${detail}`,
77
+ source: 'file',
78
+ };
79
+ }
80
+ }
81
+ /** The pseudo-path a settings view reports (there is no file to name). */
82
+ export const SETTINGS_RULES_VIEW_PATH = 'settings:dsh-perm-gate-rules';
83
+ /**
84
+ * Render the settings-sourced rules for display — the read-only face of the
85
+ * `dsh-perm-gate-rules` namespace, the twin of {@link readRulesView}.
86
+ *
87
+ * The YAML rendering of the structured object fills `raw`, so the settings card
88
+ * shows the document in the same format the file view used; `rules` carries the
89
+ * structured object itself and `source: 'settings'` states the provenance.
90
+ *
91
+ * Never throws: a doc that fails `compileRulesObject` is a state the panel has
92
+ * to render (the `error` field), not an error that should blank the section.
93
+ */
94
+ export function readRulesViewFromSettings(rules) {
95
+ let raw = '';
96
+ try {
97
+ raw = stringify(rules ?? {});
98
+ }
99
+ catch {
100
+ raw = '';
101
+ }
102
+ const lines = raw === '' ? 0 : raw.split('\n').length;
103
+ const hash = documentHash(JSON.stringify(rules ?? {}));
104
+ try {
105
+ const ruleset = compileRulesObject(rules);
106
+ return {
107
+ path: SETTINGS_RULES_VIEW_PATH, exists: true, bytes: raw.length, hash, lines,
108
+ raw, truncated: false,
109
+ defaultAction: ruleset.defaultAction,
110
+ counts: { allow: ruleset.allow.length, deny: ruleset.deny.length, ask: ruleset.ask.length },
111
+ source: 'settings',
112
+ rules,
113
+ };
114
+ }
115
+ catch (e) {
116
+ const detail = e instanceof RuleError ? e.message : String(e?.message ?? e);
117
+ return {
118
+ path: SETTINGS_RULES_VIEW_PATH, exists: true, bytes: raw.length, hash, lines,
119
+ raw, truncated: false,
120
+ counts: NO_COUNTS,
121
+ error: `does not compile: ${detail}`,
122
+ source: 'settings',
123
+ rules,
124
+ };
125
+ }
126
+ }
package/lib/runtime.d.ts CHANGED
@@ -1,17 +1,42 @@
1
- import { type AuditEntry } from './audit.js';
2
- import { type PermGateConfig, type PermissiveStrategies } from './config.js';
1
+ import { type AuditEntry, type DecisionSource } from './audit.js';
2
+ import { type PermGateConfig, type PermissiveStrategies, type RulesConfig } from './config.js';
3
3
  import type { ClassifierConfig } from './classifier.js';
4
4
  import { EventLog } from './events.js';
5
5
  import { RiskLearning } from './learning.js';
6
6
  import { type RiskRequest, type RiskVerdict } from './risk.js';
7
7
  import { type HostLlmLike, type HostModelSelection } from './host-llm.js';
8
- import { type CompiledRuleset } from './rule.js';
8
+ import { type CompiledRuleEntry, type CompiledRuleset, type RuleAction } from './rule.js';
9
9
  import type { NetworkTarget } from './network.js';
10
10
  import { type SessionEventLike } from './preset.js';
11
11
  export interface PreToolDecisionLike {
12
12
  kind: 'deny' | 'ask';
13
13
  reason: string;
14
14
  }
15
+ /**
16
+ * The P2 rule chain's own verdict for one call, as reported by
17
+ * {@link PermGateRuntime.explainRules}. Distinct from the gate's effective
18
+ * verdict: a P0 hard-deny or a preset deny-keyword fires *before* this layer and
19
+ * never leaves a rule index behind, so a differing pair is information rather
20
+ * than a contradiction.
21
+ */
22
+ export interface RuleExplanation {
23
+ readonly action: RuleAction;
24
+ readonly reason: string;
25
+ readonly ruleIndex: number | undefined;
26
+ readonly rule: CompiledRuleEntry | undefined;
27
+ readonly defaultAction: RuleAction;
28
+ }
29
+ /**
30
+ * The pure policy outcome for one call, as reported by
31
+ * {@link PermGateRuntime.explainCall}. Same chain as the host-facing decision,
32
+ * minus every side effect and minus the session-state layers.
33
+ */
34
+ export interface CallExplanation {
35
+ readonly action: RuleAction;
36
+ readonly reason: string;
37
+ readonly source: DecisionSource | 'deny-keyword' | 'cleanup-safe';
38
+ readonly ruleIndex?: number;
39
+ }
15
40
  export interface ToolExecutionLike {
16
41
  readonly name: string;
17
42
  readonly arguments: Record<string, unknown>;
@@ -153,6 +178,24 @@ export interface PermGateRuntimeOptions extends PermGateConfig {
153
178
  * Default 30 minutes.
154
179
  */
155
180
  readonly networkGrantTtlMs?: number;
181
+ /**
182
+ * Live settings-sourced rules document (the `dsh-perm-gate-rules` namespace),
183
+ * read on every (re)compile. When it carries a real configuration — entries or
184
+ * a non-default `defaultAction` — it takes precedence over `rulesFile` and the
185
+ * rule chain (settings first, file fallback); `undefined` means "namespace
186
+ * still at bare defaults", and the file paths run unchanged.
187
+ */
188
+ readonly readRulesDocument?: () => RulesConfig | undefined;
189
+ /**
190
+ * Settings-backed allowlist writer. When wired (the settings service is
191
+ * available), {@link approveAllowEverywhere} / {@link setAllowlist} go through
192
+ * it instead of rewriting the rules file; a falsy verdict (no scope, rejected
193
+ * write) falls back to the file path.
194
+ */
195
+ readonly allowlistWriter?: {
196
+ readonly append: (pattern: string, reason: string) => boolean;
197
+ readonly replace: (patterns: readonly string[], reason: string) => boolean;
198
+ };
156
199
  }
157
200
  export type CallDecision = 'allow' | 'deny' | 'ask';
158
201
  /** The effective Permissive tier state read per tool call. */
@@ -227,6 +270,12 @@ export declare class PermGateRuntime {
227
270
  * frozen events, which would otherwise re-fold on every tool call.
228
271
  */
229
272
  private presetCache;
273
+ /**
274
+ * Last stand-down announced per session (sessionId → the out-of-scope preset
275
+ * name, `''` when the session records none). Bounded by the session count and
276
+ * only read on the stand-down path — the in-scope path never touches it.
277
+ */
278
+ private readonly standDownAnnounced;
230
279
  private readonly learning;
231
280
  private readonly events?;
232
281
  /** Learning candidates awaiting human approval + execution (call fingerprint → candidate). */
@@ -325,8 +374,26 @@ export declare class PermGateRuntime {
325
374
  * `danger-full-access` means "full access without approval prompts", where a
326
375
  * forwarded ask can only fail (the approval seam rejects before any answerer
327
376
  * runs) and a hard-deny would silently overrule the tier the user chose.
377
+ *
378
+ * P0 is therefore monotonic only WITHIN the gate's scope, not across every
379
+ * preset. {@link announceStandDown} makes that visible instead of silent.
328
380
  */
329
381
  private gateActive;
382
+ /**
383
+ * Record one `stand-down` event the first time a session is observed with a
384
+ * preset outside {@link gatePresets}, and again whenever that preset changes.
385
+ *
386
+ * Per-call silence is what makes a stand-down dangerous: the tool call looks
387
+ * exactly like a call the gate inspected and allowed. One event per
388
+ * transition is enough to say otherwise, and keeps the feed (and the
389
+ * `events.jsonl` append) proportional to preset changes rather than to call
390
+ * volume.
391
+ *
392
+ * The event is a NOTICE, not a decision: it carries no allow/deny meaning,
393
+ * and the call it was observed on is still settled entirely by the selected
394
+ * tier. `verdict: 'stand-down'` labels it for the history view.
395
+ */
396
+ private announceStandDown;
330
397
  /**
331
398
  * Whether an `ask` produced while the gate is active can actually reach a
332
399
  * human. Under `approval: never` the DSH approval seam returns `rejected`
@@ -405,6 +472,45 @@ export declare class PermGateRuntime {
405
472
  * the human seam.
406
473
  */
407
474
  decideExecution(exec: ToolExecutionLike): PreToolDecisionLike | undefined;
475
+ /**
476
+ * The **pure policy** outcome for one call: the same chain
477
+ * {@link decideExecution} runs, with every side effect and every session-state
478
+ * layer removed.
479
+ *
480
+ * Side effects removed — no audit entry, no event, no ask tracking, no call
481
+ * clearance, no learning. A rule-test panel must be able to run on every
482
+ * keystroke without writing to the live decision feed.
483
+ *
484
+ * Session-state layers removed — the preset stand-down and the
485
+ * `approval: never` ask degradation both describe a session a session-less
486
+ * caller does not have. Including them is not harmless: the degradation turns
487
+ * every "the rules would ask a human" into a reported **allow**, which is the
488
+ * opposite of the truth for exactly the rules someone opens the panel to
489
+ * review (measured on the live host: `shell ls -la` reported `allow` while the
490
+ * rule layer said `ask`).
491
+ *
492
+ * `reason` is never collapsed: "allow" has causes worth naming (a rule, a
493
+ * grant, cleanup-safety, the permissive default).
494
+ */
495
+ explainCall(exec: ToolExecutionLike): CallExplanation;
496
+ /**
497
+ * P0 hard-deny → P1 session grant → P2 rules → P4 ask, as a value. Shared by
498
+ * the host-facing decision and {@link explainCall} so the two cannot drift.
499
+ */
500
+ private rawPolicy;
501
+ /**
502
+ * Rule-layer explanation for one would-be call: what the `permissions` chain
503
+ * decides on its own, before P0 hard-deny, P1 session grants, the P3
504
+ * classifier and the P4 ask ever get a say.
505
+ *
506
+ * This is the read-only half of a dry-run. It appends nothing to the audit
507
+ * mirror, records no event, mints no grant and touches no learning store, so a
508
+ * "rule test" panel may call it on every keystroke without leaving a trace in
509
+ * the decision feed. It answers a narrower question than
510
+ * {@link decideExecution} and says so: `ruleIndex` identifies a rule in the
511
+ * chain, which only this layer can attribute.
512
+ */
513
+ explainRules(exec: ToolExecutionLike): RuleExplanation;
408
514
  /**
409
515
  * Apply the Permissive tier to a raw decision. Hard-deny and minted-grant
410
516
  * outcomes are never touched (fail-closed). `alwaysConfirm` escalates a
@@ -484,15 +590,23 @@ export declare class PermGateRuntime {
484
590
  approveRepeat(exec: ToolExecutionLike, maxUses?: number, ttlMs?: number): void;
485
591
  /**
486
592
  * Grant "allow every occurrence of this command" (the always-confirm panel's
487
- * second extended allow button): persist the command into the rules file's
488
- * `allow` whitelist and reload. Returns the reload result.
593
+ * second extended allow button): persist the command into the rules' `allow`
594
+ * whitelist and reload. Prefers the settings-backed writer (the namespace's
595
+ * watch triggers the reload once the write lands); falls back to the rules
596
+ * file when no settings scope is wired. Returns the write verdict.
489
597
  */
490
598
  approveAllowEverywhere(commandWord: string, reason?: string): boolean;
491
- /** Read-only view of the current allow-list command patterns (whitelist). */
599
+ /**
600
+ * Read-only view of the current allow-list command patterns (whitelist).
601
+ * Reads the settings document when the namespace is configured, else the
602
+ * rules file.
603
+ */
492
604
  allowlist(): readonly string[];
493
605
  /**
494
606
  * Replace the whitelist with exactly the given command patterns and reload.
495
- * Returns the reload result (false when no rulesFile or the write failed).
607
+ * Prefers the settings-backed writer; falls back to the rules file when no
608
+ * settings scope is wired. Returns the write verdict (false when neither
609
+ * sink is available or the write failed).
496
610
  */
497
611
  setAllowlist(patterns: readonly string[]): boolean;
498
612
  }