@andares/pi-toolkits 0.9.8 → 0.11.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.
Files changed (39) hide show
  1. package/README.md +117 -5
  2. package/README.zh-CN.md +102 -5
  3. package/package.json +8 -3
  4. package/src/features/init/analyze.ts +162 -0
  5. package/src/features/init/apply.test.ts +264 -0
  6. package/src/features/init/apply.ts +693 -0
  7. package/src/features/init/audit.test.ts +292 -0
  8. package/src/features/init/audit.ts +586 -0
  9. package/src/features/init/coverage.test.ts +93 -0
  10. package/src/features/init/coverage.ts +163 -0
  11. package/src/features/init/detect.test.ts +237 -0
  12. package/src/features/init/detect.ts +808 -0
  13. package/src/features/init/dispatch.ts +135 -0
  14. package/src/features/init/index.test.ts +331 -0
  15. package/src/features/init/index.ts +569 -0
  16. package/src/features/init/plan.test.ts +261 -0
  17. package/src/features/init/plan.ts +867 -0
  18. package/src/features/init/render.ts +131 -0
  19. package/src/features/init/spec.ts +429 -0
  20. package/src/features/init/templates/agents-skeleton.md +44 -0
  21. package/src/features/init/templates/memory-readme.md +99 -0
  22. package/src/features/init/templates/signpost.md +15 -0
  23. package/src/features/init/templates/state-gitignore +6 -0
  24. package/src/features/init/templates.test.ts +58 -0
  25. package/src/features/init/templates.ts +79 -0
  26. package/src/features/init/test-helpers.ts +89 -0
  27. package/src/features/init/tool.ts +72 -0
  28. package/src/features/init/types.ts +284 -0
  29. package/src/features/init/ui.ts +160 -0
  30. package/src/features/permission/audit.test.ts +159 -0
  31. package/src/features/permission/audit.ts +145 -0
  32. package/src/features/permission/index.test.ts +138 -3
  33. package/src/features/permission/index.ts +108 -12
  34. package/src/features/permission/policy.ts +39 -3
  35. package/src/index.ts +9 -2
  36. package/src/lib/approval-log.test.ts +289 -0
  37. package/src/lib/approval-log.ts +724 -0
  38. package/src/lib/gates.audit.test.ts +343 -0
  39. package/src/lib/gates.ts +291 -10
@@ -0,0 +1,145 @@
1
+ /**
2
+ * permission feature — the audit payload.
3
+ *
4
+ * The gate bus owns the record envelope (session, tool call, answer, outcome);
5
+ * this module contributes the part only the permission layer knows: the four
6
+ * modes and session roots in force, the bash decomposition, and which rule
7
+ * produced the verdict.
8
+ *
9
+ * The payload is deliberately lossless for *policy analysis* — every unit and
10
+ * every target survives, including the parser's `opaque` note — while being
11
+ * bounded: units, targets, args and long strings are capped, and the final
12
+ * serializer drops detail with a visible `truncated` marker rather than
13
+ * silently cutting a field. File contents never enter this payload.
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ import type { ApprovalRecord } from "../../lib/approval-log.js";
17
+ import { clipText } from "../../lib/approval-log.js";
18
+ import type { PermissionModes } from "./constants.js";
19
+ import { canonicalize, insideAnyRoot } from "./paths.js";
20
+ import {
21
+ decisionGrantRoot,
22
+ type PolicyDecision,
23
+ } from "./policy.js";
24
+ import type { CommandUnit } from "./units.js";
25
+
26
+ const MAX_UNITS = 32;
27
+ const MAX_TARGETS = 32;
28
+ const MAX_ARGS = 64;
29
+ const ARG_CHARS = 512;
30
+ const RAW_CHARS = 8192;
31
+ const PATH_CHARS = 1024;
32
+ const NOTE_CHARS = 512;
33
+ const REASON_CHARS = 2048;
34
+
35
+ export interface PermissionAuditInput {
36
+ readonly cwd: string;
37
+ readonly modes: PermissionModes;
38
+ /** Canonical session roots (working directory plus added directories). */
39
+ readonly roots: readonly string[];
40
+ readonly units: readonly CommandUnit[];
41
+ /** Verdict for each unit, in the same order as {@link units}. */
42
+ readonly unitDecisions: readonly PolicyDecision[];
43
+ /** Aggregate verdict (the strictest unit wins). */
44
+ readonly decision: PolicyDecision;
45
+ /** Exact dialog lines, when this payload belongs to an ask. */
46
+ readonly prompt?: readonly string[];
47
+ }
48
+
49
+ /** Short digest of the command text, for grouping opaque grants. */
50
+ export function commandDigest(units: readonly CommandUnit[]): string {
51
+ return createHash("sha256")
52
+ .update(units.map((unit) => unit.raw).join(" ; "))
53
+ .digest("hex")
54
+ .slice(0, 16);
55
+ }
56
+
57
+ function serializeUnit(
58
+ unit: CommandUnit,
59
+ decision: PolicyDecision | undefined,
60
+ roots: readonly string[],
61
+ ): ApprovalRecord {
62
+ const targets = unit.targets.slice(0, MAX_TARGETS).map((target) => {
63
+ const canonical = canonicalize(target.path);
64
+ return {
65
+ path: clipText(target.path, PATH_CHARS),
66
+ canonical: clipText(canonical, PATH_CHARS),
67
+ effect: target.effect,
68
+ source: target.source,
69
+ scope: insideAnyRoot(roots, canonical) ? "in" : "out",
70
+ };
71
+ });
72
+ return {
73
+ raw: clipText(unit.raw, RAW_CHARS),
74
+ head: unit.headWord,
75
+ command: unit.command,
76
+ args: unit.args.slice(0, MAX_ARGS).map((arg) => clipText(arg, ARG_CHARS)),
77
+ base: unit.base === null ? null : clipText(canonicalize(unit.base), PATH_CHARS),
78
+ effect: unit.effect,
79
+ opaque: unit.opaque,
80
+ ...(unit.note === undefined ? {} : { note: clipText(unit.note, NOTE_CHARS) }),
81
+ ...(decision === undefined
82
+ ? {}
83
+ : { action: decision.action, layer: decision.layer }),
84
+ targets,
85
+ };
86
+ }
87
+
88
+ function serializeVerdict(
89
+ input: PermissionAuditInput,
90
+ ): ApprovalRecord {
91
+ const decision = input.decision;
92
+ const unitIndex = input.units.indexOf(decision.unit);
93
+ const target = decision.target;
94
+ const grantScope = decision.unit.opaque ? "command" : "root";
95
+ return {
96
+ action: decision.action,
97
+ layer: decision.layer,
98
+ ...(unitIndex < 0 ? {} : { unit: unitIndex }),
99
+ ...(target === undefined
100
+ ? {}
101
+ : {
102
+ target: clipText(target.path, PATH_CHARS),
103
+ effect: target.effect,
104
+ scope: target.scope,
105
+ }),
106
+ grantScope,
107
+ ...(grantScope === "root"
108
+ ? {
109
+ grantRoot: clipText(
110
+ decisionGrantRoot(decision, () => input.cwd),
111
+ PATH_CHARS,
112
+ ),
113
+ }
114
+ : { grantDigest: commandDigest(input.units) }),
115
+ ...(decision.reason === undefined
116
+ ? {}
117
+ : { reason: clipText(decision.reason, REASON_CHARS) }),
118
+ };
119
+ }
120
+
121
+ /** Structured audit fields for one permission decision. */
122
+ export function buildPermissionAudit(
123
+ input: PermissionAuditInput,
124
+ ): ApprovalRecord {
125
+ const unitDecisions = input.unitDecisions;
126
+ return {
127
+ modes: {
128
+ inRead: input.modes.inRead,
129
+ inWrite: input.modes.inWrite,
130
+ outRead: input.modes.outRead,
131
+ outWrite: input.modes.outWrite,
132
+ },
133
+ roots: input.roots
134
+ .slice(0, 16)
135
+ .map((root) => clipText(root, PATH_CHARS)),
136
+ units: input.units
137
+ .slice(0, MAX_UNITS)
138
+ .map((unit, index) => serializeUnit(unit, unitDecisions[index], input.roots)),
139
+ ...(input.units.length > MAX_UNITS
140
+ ? { unitsOmitted: input.units.length - MAX_UNITS }
141
+ : {}),
142
+ verdict: serializeVerdict(input),
143
+ ...(input.prompt === undefined ? {} : { prompt: [...input.prompt] }),
144
+ };
145
+ }
@@ -6,7 +6,7 @@
6
6
  * selector, and the rest through the gate's verdicts and the command handler.
7
7
  */
8
8
  import { mkdtempSync, realpathSync } from "node:fs";
9
- import { tmpdir } from "node:os";
9
+ import { homedir, tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { beforeEach, describe, expect, it } from "vitest";
12
12
  import type {
@@ -14,6 +14,7 @@ import type {
14
14
  ExtensionContext,
15
15
  ToolCallEvent,
16
16
  } from "@earendil-works/pi-coding-agent";
17
+ import type { ApprovalLog, ApprovalRecord } from "../../lib/approval-log.js";
17
18
  import type { AskDecision, GateHub, ToolGate } from "../../lib/gates.js";
18
19
  import {
19
20
  clearJailedTool,
@@ -65,7 +66,7 @@ interface Harness {
65
66
  banner(): { systemPrompt: string } | undefined;
66
67
  }
67
68
 
68
- function makeHarness(): Harness {
69
+ function makeHarness(audit?: ApprovalLog): Harness {
69
70
  const cwd = realpathSync(mkdtempSync(join(tmpdir(), "pi-perm-test-")));
70
71
  const statuses: Harness["statuses"] = [];
71
72
  const notes: Harness["notes"] = [];
@@ -116,7 +117,7 @@ function makeHarness(): Harness {
116
117
  registerEntryRenderer() {},
117
118
  } as unknown as ExtensionAPI;
118
119
 
119
- registerPermission(pi, hub);
120
+ registerPermission(pi, hub, { audit });
120
121
  const gate = captured[0];
121
122
  if (!gate || !commandHandler) throw new Error("permission did not register");
122
123
 
@@ -154,6 +155,7 @@ interface AskVerdict {
154
155
  request: {
155
156
  details: readonly string[];
156
157
  root: string;
158
+ audit?: ApprovalRecord;
157
159
  present(ctx: ExtensionContext): Promise<AskDecision | undefined>;
158
160
  apply(decision: AskDecision): void;
159
161
  };
@@ -435,3 +437,136 @@ describe("self-jailed tools", () => {
435
437
  expect(asAsk(await h.evaluate(customEvent("scratchpad"))).kind).toBe("ask");
436
438
  });
437
439
  });
440
+
441
+ describe("audit log", () => {
442
+ function makeAudit(): { audit: ApprovalLog; records: ApprovalRecord[] } {
443
+ const records: ApprovalRecord[] = [];
444
+ return {
445
+ records,
446
+ audit: {
447
+ dir: "/tmp/pi-perm-audit",
448
+ mode: "interesting",
449
+ active: true,
450
+ append: (record) => records.push(record),
451
+ },
452
+ };
453
+ }
454
+
455
+ it("attaches the decomposition to an ask verdict", async () => {
456
+ const { audit } = makeAudit();
457
+ const h = makeHarness(audit);
458
+ await h.command("allow allow allow ask");
459
+ const verdict = asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/x")));
460
+ expect(verdict.request.audit).toMatchObject({
461
+ modes: { outWrite: "ask" },
462
+ verdict: {
463
+ action: "ask",
464
+ layer: "mode",
465
+ target: "/tmp/outside/x",
466
+ scope: "out",
467
+ },
468
+ });
469
+ expect(
470
+ (verdict.request.audit?.units as Array<Record<string, unknown>>)[0],
471
+ ).toMatchObject({
472
+ command: "rm",
473
+ opaque: false,
474
+ action: "ask",
475
+ layer: "mode",
476
+ });
477
+ });
478
+
479
+ it("attaches the deciding layer to a deny verdict", async () => {
480
+ const { audit } = makeAudit();
481
+ const h = makeHarness(audit);
482
+ await h.command("allow allow allow ask");
483
+ const verdict = (await h.evaluate(
484
+ bashEvent(`cat ${homedir()}/.ssh/id_rsa`),
485
+ )) as {
486
+ kind: "block";
487
+ reason: string;
488
+ audit?: ApprovalRecord;
489
+ };
490
+ expect(verdict.kind).toBe("block");
491
+ expect(verdict.audit?.verdict).toMatchObject({
492
+ action: "deny",
493
+ layer: "secret",
494
+ });
495
+ });
496
+
497
+ it("records mode changes with their source", async () => {
498
+ const { audit, records } = makeAudit();
499
+ const h = makeHarness(audit);
500
+ await h.command("allow allow allow ask");
501
+ expect(records.at(-1)).toMatchObject({
502
+ event: "mode",
503
+ enabled: true,
504
+ source: "args",
505
+ via: "manual",
506
+ modes: { inRead: "allow", outWrite: "ask" },
507
+ });
508
+ await h.command("");
509
+ expect(records.at(-1)).toMatchObject({
510
+ event: "mode",
511
+ enabled: false,
512
+ source: "toggle",
513
+ });
514
+ });
515
+
516
+ it("marks an autostart-dispatched mode change", async () => {
517
+ const { audit, records } = makeAudit();
518
+ const h = makeHarness(audit);
519
+ markStartupCommand("ask-perm");
520
+ await h.command("allow allow allow ask");
521
+ expect(records.at(-1)).toMatchObject({
522
+ event: "mode",
523
+ via: "autostart",
524
+ });
525
+ });
526
+ });
527
+
528
+ describe("audit log — session-grant allows", () => {
529
+ function makeAudit(): { audit: ApprovalLog; records: ApprovalRecord[] } {
530
+ const records: ApprovalRecord[] = [];
531
+ return {
532
+ records,
533
+ audit: {
534
+ dir: "/tmp/pi-perm-audit",
535
+ mode: "interesting",
536
+ active: true,
537
+ append: (record) => records.push(record),
538
+ },
539
+ };
540
+ }
541
+
542
+ it("records the repeat call a directory grant made free", async () => {
543
+ const { audit, records } = makeAudit();
544
+ const h = makeHarness(audit);
545
+ await h.command("allow allow allow ask");
546
+ const first = asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/x")));
547
+ first.request.apply("allow-session");
548
+ expect(await h.evaluate(bashEvent("rm -rf /tmp/outside/y"))).toBeUndefined();
549
+
550
+ const allow = records.find((record) => record.event === "allow");
551
+ expect(allow).toMatchObject({
552
+ event: "allow",
553
+ verdict: { action: "allow", layer: "session-allow", scope: "out" },
554
+ });
555
+ });
556
+
557
+ it("records a program-level grant on an opaque command too", async () => {
558
+ const { audit, records } = makeAudit();
559
+ const h = makeHarness(audit);
560
+ await h.command("allow allow allow ask");
561
+ const command = 'cd "$DIR" && rm x';
562
+ const first = asAsk(await h.evaluate(bashEvent(command)));
563
+ first.request.apply("allow-session");
564
+ expect(await h.evaluate(bashEvent(command))).toBeUndefined();
565
+
566
+ const allow = records.find((record) => record.event === "allow");
567
+ expect(allow).toMatchObject({
568
+ event: "allow",
569
+ verdict: { action: "allow", layer: "session-command", grantScope: "command" },
570
+ });
571
+ });
572
+ });
@@ -22,6 +22,8 @@ import type {
22
22
  ToolCallEvent,
23
23
  } from "@earendil-works/pi-coding-agent";
24
24
  import { Text } from "@earendil-works/pi-tui";
25
+ import type { ApprovalLog, ApprovalRecord } from "../../lib/approval-log.js";
26
+ import { auditBase, auditTool } from "../../lib/approval-log.js";
25
27
  import type { AskRequest, GateHub } from "../../lib/gates.js";
26
28
  import { isJailedTool } from "../../lib/tools.js";
27
29
  import { consumeStartupCommand } from "../../lib/startup.js";
@@ -46,6 +48,7 @@ import {
46
48
  } from "./paths.js";
47
49
  import {
48
50
  decideProgram,
51
+ decideUnit,
49
52
  decisionEffect,
50
53
  decisionGrantRoot,
51
54
  formatDenial,
@@ -55,6 +58,7 @@ import {
55
58
  import { askPermission } from "./prompt.js";
56
59
  import { SessionRules } from "./session-rules.js";
57
60
  import { parseCommandUnits, type CommandUnit } from "./units.js";
61
+ import { buildPermissionAudit, commandDigest } from "./audit.js";
58
62
 
59
63
  /** Tools that read one path. */
60
64
  const READ_PATH_TOOLS = new Set(["read", "grep", "find", "ls"]);
@@ -123,7 +127,17 @@ interface NoticeData {
123
127
  text: string;
124
128
  }
125
129
 
126
- export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
130
+ export interface PermissionOptions {
131
+ /** Write-only audit sink; kept optional so tests need no filesystem. */
132
+ readonly audit?: ApprovalLog;
133
+ }
134
+
135
+ export function registerPermission(
136
+ pi: ExtensionAPI,
137
+ gates: GateHub,
138
+ options: PermissionOptions = {},
139
+ ): void {
140
+ const audit = options.audit;
127
141
  pi.registerEntryRenderer?.(NOTICE_ENTRY, (entry, _options, theme) => {
128
142
  const text = (entry.data as NoticeData | undefined)?.text;
129
143
  return text === undefined
@@ -148,6 +162,22 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
148
162
  rootsCache = undefined;
149
163
  }
150
164
 
165
+ /** Record an on/off or mode change; never affects the state machine. */
166
+ function recordMode(
167
+ ctx: ExtensionContext,
168
+ state: { enabled: boolean; source: string; via: string },
169
+ ): void {
170
+ if (!audit || audit.mode === "off") return;
171
+ audit.append({
172
+ ...auditBase(ctx),
173
+ event: "mode",
174
+ enabled: state.enabled,
175
+ modes: { ...modes },
176
+ source: state.source,
177
+ via: state.via,
178
+ });
179
+ }
180
+
151
181
  /** Session id, or `undefined` when the host exposes none. */
152
182
  function sessionIdOf(ctx: ExtensionContext): string | undefined {
153
183
  try {
@@ -234,11 +264,13 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
234
264
  decision: PolicyDecision,
235
265
  fallbackCwd: string,
236
266
  units: readonly CommandUnit[],
267
+ payload: ApprovalRecord | undefined,
237
268
  ): AskRequest {
238
269
  const root = decisionGrantRoot(decision, () => fallbackCwd);
239
270
  const effect = decisionEffect(decision);
240
271
  const scope =
241
272
  decision.target?.scope === "out" ? "outside project" : "inside project";
273
+ const title = "Permission required";
242
274
  const details = [
243
275
  `Command: ${decision.unit.raw || decision.unit.command}`,
244
276
  ...(decision.target
@@ -249,12 +281,16 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
249
281
  ? "Session grant would cover: this exact command"
250
282
  : `Session grant would cover: ${root}`,
251
283
  ];
284
+ const auditPayload =
285
+ payload === undefined
286
+ ? undefined
287
+ : { ...payload, prompt: [title, ...details] };
252
288
  return {
253
- title: "Permission required",
289
+ title,
254
290
  details,
255
291
  root,
256
- present: (ctx) =>
257
- askPermission(ctx, { title: "Permission required", details }),
292
+ audit: auditPayload,
293
+ present: (ctx) => askPermission(ctx, { title, details }),
258
294
  apply: (choice) => {
259
295
  if (choice === "allow-session") {
260
296
  if (decision.unit.opaque) rules.allowCommand(programKey(units));
@@ -273,6 +309,20 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
273
309
  ? `${decision.reason ?? "operation"} — denied for this session`
274
310
  : (decision.reason ?? "denied by the operator"),
275
311
  }),
312
+ settle: (choice) => {
313
+ if (choice !== "allow-session" && choice !== "deny-session") {
314
+ return undefined;
315
+ }
316
+ const commandScope = decision.unit.opaque;
317
+ return {
318
+ grant: {
319
+ kind: choice === "allow-session" ? "allow" : "deny",
320
+ scope: commandScope ? "command" : "root",
321
+ effect,
322
+ ...(commandScope ? { digest: commandDigest(units) } : { root }),
323
+ },
324
+ };
325
+ },
276
326
  };
277
327
  }
278
328
 
@@ -298,6 +348,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
298
348
  handler: async (args, ctx) => {
299
349
  // Startup notices are entries; interactive ones stay in-place toasts.
300
350
  const startup = consumeStartupCommand("ask-perm");
351
+ const via = startup ? "autostart" : "manual";
301
352
  const notice = (text: string): void => {
302
353
  if (startup) pi.appendEntry<NoticeData>(NOTICE_ENTRY, { text });
303
354
  else ctx.ui.notify(text, "info");
@@ -322,6 +373,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
322
373
  if (changed) rules.clear();
323
374
  enabled = true;
324
375
  syncStatus(ctx);
376
+ recordMode(ctx, { enabled: true, source: "args", via });
325
377
  notice(`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`);
326
378
  return;
327
379
  }
@@ -330,6 +382,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
330
382
  enabled = false;
331
383
  rules.clear();
332
384
  syncStatus(ctx);
385
+ recordMode(ctx, { enabled: false, source: "toggle", via });
333
386
  notice("Permission OFF — no boundary checks in this session.");
334
387
  return;
335
388
  }
@@ -346,6 +399,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
346
399
  rules.clear();
347
400
  enabled = true;
348
401
  syncStatus(ctx);
402
+ recordMode(ctx, { enabled: true, source: "panel", via });
349
403
  notice(`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`);
350
404
  },
351
405
  });
@@ -364,16 +418,58 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
364
418
  if (isJailedTool(event.toolName)) return undefined;
365
419
  const units = unitsFor(event, ctx.cwd);
366
420
  if (units.length === 0) return undefined;
367
- const decision = decideProgram(units, {
368
- modes,
369
- roots: rootsFor(ctx),
370
- rules,
371
- });
372
- if (decision.action === "allow") return undefined;
421
+ const roots = rootsFor(ctx);
422
+ const policyCtx = { modes, roots, rules };
423
+ const decision = decideProgram(units, policyCtx);
424
+ const logOn = audit !== undefined && audit.mode !== "off";
425
+
426
+ if (decision.action === "allow") {
427
+ // Session-grant allows show how much friction a grant removed; plain
428
+ // mode allows are logged only under `PI_APPROVAL_LOG=all`, otherwise
429
+ // their volume would bury the interesting records. A grant on an
430
+ // opaque program is keyed by its text (`session-command`), so both
431
+ // grant layers must be recorded — otherwise "allow for this session"
432
+ // on a command the parser cannot resolve would leave no trace.
433
+ const granted =
434
+ decision.layer === "session-allow" ||
435
+ decision.layer === "session-command";
436
+ if (logOn && (granted || audit?.mode === "all")) {
437
+ const payload = buildPermissionAudit({
438
+ cwd: ctx.cwd,
439
+ modes,
440
+ roots,
441
+ units,
442
+ unitDecisions: units.map((unit) => decideUnit(unit, policyCtx)),
443
+ decision,
444
+ });
445
+ audit?.append({
446
+ ...auditBase(ctx),
447
+ ...auditTool(event),
448
+ ...payload,
449
+ event: "allow",
450
+ });
451
+ }
452
+ return undefined;
453
+ }
454
+
455
+ let payload: ApprovalRecord | undefined;
456
+ if (logOn) {
457
+ payload = buildPermissionAudit({
458
+ cwd: ctx.cwd,
459
+ modes,
460
+ roots,
461
+ units,
462
+ unitDecisions: units.map((unit) => decideUnit(unit, policyCtx)),
463
+ decision,
464
+ });
465
+ }
373
466
  if (decision.action === "deny") {
374
- return { kind: "block", reason: formatDenial(decision) };
467
+ return { kind: "block", reason: formatDenial(decision), audit: payload };
375
468
  }
376
- return { kind: "ask", request: askRequest(decision, ctx.cwd, units) };
469
+ return {
470
+ kind: "ask",
471
+ request: askRequest(decision, ctx.cwd, units, payload),
472
+ };
377
473
  },
378
474
  });
379
475
 
@@ -30,12 +30,31 @@ export interface PolicyTarget {
30
30
 
31
31
  export interface PolicyDecision {
32
32
  readonly action: PolicyAction;
33
+ /** Machine-readable reason class, for the audit log and tests. */
34
+ readonly layer: PolicyLayer;
33
35
  readonly unit: CommandUnit;
34
36
  readonly target?: PolicyTarget;
35
37
  /** Human/model-facing explanation for ask and deny. */
36
38
  readonly reason?: string;
37
39
  }
38
40
 
41
+ /**
42
+ * Which rule decided a unit: the four modes, one of the fixed tables, a session
43
+ * grant, or a parser fallback. Kept separate from the human-facing reason so
44
+ * the audit log can group decisions without parsing English.
45
+ */
46
+ export type PolicyLayer =
47
+ | "mode"
48
+ | "secret"
49
+ | "session-allow"
50
+ | "session-deny"
51
+ | "session-command"
52
+ | "danger"
53
+ | "privileged"
54
+ | "opaque"
55
+ | "unresolved"
56
+ | "empty";
57
+
39
58
  export interface PolicyContext {
40
59
  readonly modes: PermissionModes;
41
60
  /** Canonical session roots (working directory plus added directories). */
@@ -81,6 +100,7 @@ function decidePath(
81
100
  if (secret !== undefined) {
82
101
  return {
83
102
  action: "deny",
103
+ layer: "secret",
84
104
  unit,
85
105
  target: { path: pathValue, effect, scope },
86
106
  reason: `${secret} — denied by the fixed safety table`,
@@ -90,19 +110,23 @@ function decidePath(
90
110
  if (ctx.rules.denies(effect, pathValue)) {
91
111
  return {
92
112
  action: "deny",
113
+ layer: "session-deny",
93
114
  unit,
94
115
  target,
95
116
  reason: `${effect} under ${pathValue} was denied for this session`,
96
117
  };
97
118
  }
98
119
  if (ctx.rules.allows(effect, pathValue)) {
99
- return { action: "allow", unit, target };
120
+ return { action: "allow", layer: "session-allow", unit, target };
100
121
  }
101
122
  const mode = modeFor(ctx.modes, effect, scope);
102
- if (mode === "allow") return { action: "allow", unit, target };
123
+ if (mode === "allow") {
124
+ return { action: "allow", layer: "mode", unit, target };
125
+ }
103
126
  if (mode === "deny") {
104
127
  return {
105
128
  action: "deny",
129
+ layer: "mode",
106
130
  unit,
107
131
  target,
108
132
  reason: `${scope === "out" ? "outside-project" : "in-project"} ${effect} is set to deny`,
@@ -110,6 +134,7 @@ function decidePath(
110
134
  }
111
135
  return {
112
136
  action: "ask",
137
+ layer: "mode",
113
138
  unit,
114
139
  target,
115
140
  reason:
@@ -130,6 +155,7 @@ export function decideUnit(
130
155
  // to grant.
131
156
  return {
132
157
  action: "ask",
158
+ layer: "opaque",
133
159
  unit,
134
160
  reason: unit.note ?? "this command could not be analysed",
135
161
  };
@@ -149,6 +175,7 @@ export function decideUnit(
149
175
  if (secret?.reason !== undefined) {
150
176
  return {
151
177
  action: "deny",
178
+ layer: "secret",
152
179
  unit,
153
180
  target: {
154
181
  path: secret.target.path,
@@ -163,6 +190,7 @@ export function decideUnit(
163
190
  if (baseSecret !== undefined) {
164
191
  return {
165
192
  action: "deny",
193
+ layer: "secret",
166
194
  unit,
167
195
  target: {
168
196
  path: canonicalBase,
@@ -221,6 +249,7 @@ export function decideUnit(
221
249
  }
222
250
  return {
223
251
  action: "ask",
252
+ layer: danger !== undefined ? "danger" : "privileged",
224
253
  unit,
225
254
  target,
226
255
  reason: danger ?? "privileged command",
@@ -231,6 +260,7 @@ export function decideUnit(
231
260
  if (canonicalBase === null) {
232
261
  return {
233
262
  action: "ask",
263
+ layer: "unresolved",
234
264
  unit,
235
265
  reason: "the working directory could not be resolved",
236
266
  };
@@ -263,6 +293,7 @@ export function decideProgram(
263
293
  if (units.length === 0) {
264
294
  return {
265
295
  action: "allow",
296
+ layer: "empty",
266
297
  unit: {
267
298
  raw: "",
268
299
  headWord: "",
@@ -284,12 +315,17 @@ export function decideProgram(
284
315
  if (ctx.rules.deniesCommand(key)) {
285
316
  return {
286
317
  action: "deny",
318
+ layer: "session-command",
287
319
  unit: opaqueUnit,
288
320
  reason: "this command was denied for this session",
289
321
  };
290
322
  }
291
323
  if (ctx.rules.allowsCommand(key)) {
292
- return { action: "allow", unit: opaqueUnit };
324
+ return {
325
+ action: "allow",
326
+ layer: "session-command",
327
+ unit: opaqueUnit,
328
+ };
293
329
  }
294
330
  }
295
331