@gotgenes/pi-permission-system 15.0.1 → 16.0.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,72 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { DecisionAudit } from "#src/decision-audit";
4
+
5
+ function makeAuditLogger() {
6
+ return {
7
+ debug: vi.fn<(event: string, details?: Record<string, unknown>) => void>(),
8
+ warn: vi.fn<(message: string) => void>(),
9
+ };
10
+ }
11
+
12
+ describe("DecisionAudit", () => {
13
+ it("counts allowed, blocked, and error decisions in the summary", () => {
14
+ const audit = new DecisionAudit();
15
+ audit.recordDecision("allow");
16
+ audit.recordDecision("allow");
17
+ audit.recordDecision("block");
18
+ audit.recordError();
19
+
20
+ const logger = makeAuditLogger();
21
+ audit.writeSummary(logger);
22
+
23
+ expect(logger.debug).toHaveBeenCalledWith("permission.session_summary", {
24
+ toolCalls: 4,
25
+ allowed: 2,
26
+ blocked: 1,
27
+ errors: 1,
28
+ });
29
+ });
30
+
31
+ it("emits a zeroed summary when no calls were recorded", () => {
32
+ const audit = new DecisionAudit();
33
+ const logger = makeAuditLogger();
34
+
35
+ audit.writeSummary(logger);
36
+
37
+ expect(logger.debug).toHaveBeenCalledWith("permission.session_summary", {
38
+ toolCalls: 0,
39
+ allowed: 0,
40
+ blocked: 0,
41
+ errors: 0,
42
+ });
43
+ });
44
+
45
+ it("does not warn when the counts are consistent", () => {
46
+ const audit = new DecisionAudit();
47
+ audit.recordDecision("allow");
48
+ audit.recordError();
49
+
50
+ const logger = makeAuditLogger();
51
+ audit.writeSummary(logger);
52
+
53
+ expect(logger.warn).not.toHaveBeenCalled();
54
+ });
55
+
56
+ it("warns when the per-call invariant is violated", () => {
57
+ const audit = new DecisionAudit();
58
+ audit.recordDecision("allow");
59
+ // Force a re-opened silent path: bump the private total without a matching
60
+ // sub-total, simulating a future regression that resolves a call without
61
+ // recording its terminal decision.
62
+ (audit as unknown as { toolCalls: number }).toolCalls++;
63
+
64
+ const logger = makeAuditLogger();
65
+ audit.writeSummary(logger);
66
+
67
+ expect(logger.warn).toHaveBeenCalledTimes(1);
68
+ expect(logger.warn).toHaveBeenCalledWith(
69
+ expect.stringContaining("invariant violated"),
70
+ );
71
+ });
72
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { detectPermissiveBashFallback } from "#src/config-loader";
4
+ import type { FlatPermissionConfig } from "#src/types";
5
+
6
+ describe("detectPermissiveBashFallback", () => {
7
+ it("warns when top-level '*' is allow and bash is absent", () => {
8
+ const permission: FlatPermissionConfig = { "*": "allow" };
9
+
10
+ const issue = detectPermissiveBashFallback(permission);
11
+
12
+ expect(issue).toBeDefined();
13
+ expect(issue).toContain("bash");
14
+ expect(issue).toContain("allow");
15
+ });
16
+
17
+ it("warns when top-level '*' is allow and bash map has no '*' key", () => {
18
+ const permission: FlatPermissionConfig = {
19
+ "*": "allow",
20
+ bash: { "git *": "ask" },
21
+ };
22
+
23
+ expect(detectPermissiveBashFallback(permission)).toBeDefined();
24
+ });
25
+
26
+ it("does not warn when bash is a bare string surface", () => {
27
+ const permission: FlatPermissionConfig = { "*": "allow", bash: "ask" };
28
+
29
+ expect(detectPermissiveBashFallback(permission)).toBeUndefined();
30
+ });
31
+
32
+ it("does not warn when bash map has an explicit '*' key", () => {
33
+ const permission: FlatPermissionConfig = {
34
+ "*": "allow",
35
+ bash: { "*": "ask", "git *": "allow" },
36
+ };
37
+
38
+ expect(detectPermissiveBashFallback(permission)).toBeUndefined();
39
+ });
40
+
41
+ it("does not warn when top-level '*' is not allow", () => {
42
+ const permission: FlatPermissionConfig = { "*": "ask" };
43
+
44
+ expect(detectPermissiveBashFallback(permission)).toBeUndefined();
45
+ });
46
+
47
+ it("does not warn when top-level '*' is absent", () => {
48
+ const permission: FlatPermissionConfig = { bash: { "git *": "ask" } };
49
+
50
+ expect(detectPermissiveBashFallback(permission)).toBeUndefined();
51
+ });
52
+
53
+ it("does not warn when permission is undefined", () => {
54
+ expect(detectPermissiveBashFallback(undefined)).toBeUndefined();
55
+ });
56
+ });
@@ -100,7 +100,7 @@ describe("external_directory path scope", () => {
100
100
  const result = await handler.handleToolCall(event, makeCtx());
101
101
  // Should not be blocked — the external_directory gate is skipped,
102
102
  // and the tool gate sees "allow" (default toolState in makeExtDirCheck)
103
- expect(result).toEqual({});
103
+ expect(result).toEqual({ action: "allow" });
104
104
  });
105
105
 
106
106
  it("fires external_directory check when path is outside CWD", async () => {
@@ -110,7 +110,7 @@ describe("external_directory path scope", () => {
110
110
  });
111
111
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
112
112
  const result = await handler.handleToolCall(event, makeCtx());
113
- expect(result).toMatchObject({ block: true });
113
+ expect(result).toMatchObject({ action: "block" });
114
114
  });
115
115
 
116
116
  it("skips external_directory check for non-path-bearing tool (bash)", async () => {
@@ -142,7 +142,7 @@ describe("external_directory path scope", () => {
142
142
  input: { path: EXTERNAL_PATH },
143
143
  });
144
144
  const result = await handler.handleToolCall(event, makeCtx());
145
- expect(result).toMatchObject({ block: true });
145
+ expect(result).toMatchObject({ action: "block" });
146
146
  });
147
147
 
148
148
  it.each(
@@ -155,7 +155,7 @@ describe("external_directory path scope", () => {
155
155
  // No path in input — external_directory gate should not fire
156
156
  const event = makeToolCallEvent(toolName);
157
157
  const result = await handler.handleToolCall(event, makeCtx());
158
- expect(result).toEqual({});
158
+ expect(result).toEqual({ action: "allow" });
159
159
  });
160
160
  });
161
161
 
@@ -169,7 +169,7 @@ describe("external_directory policy state — allow", () => {
169
169
  });
170
170
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
171
171
  const result = await handler.handleToolCall(event, makeCtx());
172
- expect(result).toEqual({});
172
+ expect(result).toEqual({ action: "allow" });
173
173
  });
174
174
 
175
175
  it("emits decision event with policy_allow on external_directory surface", async () => {
@@ -214,7 +214,7 @@ describe("external_directory — allow external reads, gate external writes (#14
214
214
  });
215
215
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
216
216
  const result = await handler.handleToolCall(event, makeCtx());
217
- expect(result).toEqual({});
217
+ expect(result).toEqual({ action: "allow" });
218
218
  });
219
219
 
220
220
  it("prompts for write to external path when external_directory allows but write is ask", async () => {
@@ -233,7 +233,7 @@ describe("external_directory — allow external reads, gate external writes (#14
233
233
  });
234
234
  const result = await handler.handleToolCall(event, makeCtx());
235
235
  // external_directory passes; write gate prompts and user approves
236
- expect(result).toEqual({});
236
+ expect(result).toEqual({ action: "allow" });
237
237
  expect(prompter.prompt).toHaveBeenCalledOnce();
238
238
  });
239
239
 
@@ -246,7 +246,7 @@ describe("external_directory — allow external reads, gate external writes (#14
246
246
  input: { path: EXTERNAL_PATH },
247
247
  });
248
248
  const result = await handler.handleToolCall(event, makeCtx());
249
- expect(result.block).toBe(true);
249
+ expect(result).toMatchObject({ action: "block" });
250
250
  });
251
251
 
252
252
  it("emits separate decision events for external_directory and write surfaces", async () => {
@@ -284,8 +284,8 @@ describe("external_directory policy state — deny", () => {
284
284
  });
285
285
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
286
286
  const result = await handler.handleToolCall(event, makeCtx());
287
- expect(result.block).toBe(true);
288
- expect(result.reason).toContain(EXTERNAL_PATH);
287
+ expect(result).toMatchObject({ action: "block" });
288
+ expect((result as { reason?: string }).reason).toContain(EXTERNAL_PATH);
289
289
  });
290
290
 
291
291
  it("block reason contains extension attribution", async () => {
@@ -295,8 +295,10 @@ describe("external_directory policy state — deny", () => {
295
295
  });
296
296
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
297
297
  const result = await handler.handleToolCall(event, makeCtx());
298
- expect(result.reason).toContain("[pi-permission-system]");
299
- expect(result.reason).not.toContain("Hard stop");
298
+ expect((result as { reason?: string }).reason).toContain(
299
+ "[pi-permission-system]",
300
+ );
301
+ expect((result as { reason?: string }).reason).not.toContain("Hard stop");
300
302
  });
301
303
 
302
304
  it("writes review-log entry with resolution policy_denied", async () => {
@@ -351,7 +353,7 @@ describe("external_directory policy state — ask", () => {
351
353
  });
352
354
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
353
355
  const result = await handler.handleToolCall(event, makeCtx());
354
- expect(result).toEqual({});
356
+ expect(result).toEqual({ action: "allow" });
355
357
  });
356
358
 
357
359
  it("emits user_approved decision when user approves", async () => {
@@ -391,7 +393,7 @@ describe("external_directory policy state — ask", () => {
391
393
  });
392
394
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
393
395
  const result = await handler.handleToolCall(event, makeCtx());
394
- expect(result.block).toBe(true);
396
+ expect(result).toMatchObject({ action: "block" });
395
397
  });
396
398
 
397
399
  it("emits user_denied decision when user denies", async () => {
@@ -433,8 +435,8 @@ describe("external_directory policy state — ask", () => {
433
435
  });
434
436
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
435
437
  const result = await handler.handleToolCall(event, makeCtx());
436
- expect(result.block).toBe(true);
437
- expect(result.reason).toContain("not needed");
438
+ expect(result).toMatchObject({ action: "block" });
439
+ expect((result as { reason?: string }).reason).toContain("not needed");
438
440
  });
439
441
 
440
442
  it("blocks with confirmation_unavailable when no UI is available", async () => {
@@ -451,8 +453,10 @@ describe("external_directory policy state — ask", () => {
451
453
  event,
452
454
  makeCtx({ hasUI: false }),
453
455
  );
454
- expect(result.block).toBe(true);
455
- expect(result.reason).toContain("outside the working directory");
456
+ expect(result).toMatchObject({ action: "block" });
457
+ expect((result as { reason?: string }).reason).toContain(
458
+ "outside the working directory",
459
+ );
456
460
  });
457
461
 
458
462
  it("writes review-log entry with confirmation_unavailable when no UI", async () => {
@@ -541,7 +545,7 @@ describe("external_directory per-agent override", () => {
541
545
  });
542
546
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
543
547
  const result1 = await handler1.handleToolCall(event, makeCtx());
544
- expect(result1).toEqual({});
548
+ expect(result1).toEqual({ action: "allow" });
545
549
 
546
550
  const decisions1 = getDecisionEvents(events1);
547
551
  const extDir1 = decisions1.find((d) => d.surface === "external_directory");
@@ -560,7 +564,7 @@ describe("external_directory per-agent override", () => {
560
564
  tools: ALL_TOOLS,
561
565
  });
562
566
  const result2 = await handler2.handleToolCall(event, makeCtx());
563
- expect(result2).toMatchObject({ block: true });
567
+ expect(result2).toMatchObject({ action: "block" });
564
568
  });
565
569
  });
566
570
 
@@ -161,7 +161,7 @@ describe("external-directory session dedup", () => {
161
161
  input: { path: externalPath },
162
162
  };
163
163
  const result1 = await handler.handleToolCall(event1, ctx);
164
- expect(result1).toEqual({});
164
+ expect(result1).toEqual({ action: "allow" });
165
165
  expect(prompter.prompt).toHaveBeenCalledTimes(1);
166
166
 
167
167
  // Second call — same path, should hit session rule, no prompt
@@ -172,7 +172,7 @@ describe("external-directory session dedup", () => {
172
172
  input: { path: externalPath },
173
173
  };
174
174
  const result2 = await handler.handleToolCall(event2, ctx);
175
- expect(result2).toEqual({});
175
+ expect(result2).toEqual({ action: "allow" });
176
176
  expect(prompter.prompt).toHaveBeenCalledTimes(1);
177
177
  });
178
178
 
@@ -272,7 +272,7 @@ describe("external-directory session dedup", () => {
272
272
  input: { command: "echo hello > /tmp/out.txt" },
273
273
  };
274
274
  const result1 = await handler.handleToolCall(event1, ctx);
275
- expect(result1).toEqual({});
275
+ expect(result1).toEqual({ action: "allow" });
276
276
  expect(prompter.prompt).toHaveBeenCalledTimes(1);
277
277
 
278
278
  // Second call — different bash command, same external path
@@ -283,7 +283,7 @@ describe("external-directory session dedup", () => {
283
283
  input: { command: "cat /tmp/out.txt" },
284
284
  };
285
285
  const result2 = await handler.handleToolCall(event2, ctx);
286
- expect(result2).toEqual({});
286
+ expect(result2).toEqual({ action: "allow" });
287
287
  expect(prompter.prompt).toHaveBeenCalledTimes(1);
288
288
  });
289
289
 
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Metamorphic totality property for the bash command gate (#452, A3).
3
+ *
4
+ * Wrapping any `ask`/`deny` command in `cd /x && <cmd>` must not weaken the
5
+ * decision — the chain decomposition + most-restrictive-wins, combined with the
6
+ * fail-closed empty-parse fallback, guarantees a `cd …` prefix can never let a
7
+ * gated command ride a permissive top-level `*`.
8
+ *
9
+ * A focused parametrized table over the real tree-sitter parse + resolve, not a
10
+ * full fuzzer (tree-sitter fuzzing is brittle); it pins A3 directly.
11
+ */
12
+ import { describe, expect, it } from "vitest";
13
+
14
+ import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
15
+ import { BashProgram } from "#src/handlers/gates/bash-program";
16
+ import type { ScopedPermissionResolver } from "#src/permission-resolver";
17
+ import type { PermissionState } from "#src/types";
18
+
19
+ import { makeCheckResult } from "#test/helpers/handler-fixtures";
20
+
21
+ /** Decision strength ordering: deny (2) > ask (1) > allow (0). */
22
+ const STRENGTH: Record<PermissionState, number> = {
23
+ allow: 0,
24
+ ask: 1,
25
+ deny: 2,
26
+ };
27
+
28
+ /**
29
+ * Resolver whose decision keys on a command substring → state map. A command
30
+ * matching no entry resolves to allow (the permissive top-level `*`).
31
+ */
32
+ function makeKeyedResolver(
33
+ rules: { match: string; state: PermissionState }[],
34
+ ): ScopedPermissionResolver {
35
+ return {
36
+ resolve: (_surface: string, input: { command?: string }) => {
37
+ const command = input.command ?? "";
38
+ const rule = rules.find((r) => command.includes(r.match));
39
+ const state: PermissionState = rule?.state ?? "allow";
40
+ return makeCheckResult({ state, source: "bash", command });
41
+ },
42
+ resolvePathPolicy: () => makeCheckResult(),
43
+ };
44
+ }
45
+
46
+ async function decide(
47
+ command: string,
48
+ resolver: ScopedPermissionResolver,
49
+ ): Promise<PermissionState> {
50
+ const program = await BashProgram.parse(command);
51
+ return resolveBashCommandCheck(
52
+ command,
53
+ program.commands(),
54
+ undefined,
55
+ resolver,
56
+ ).state;
57
+ }
58
+
59
+ describe("bash command gate — metamorphic totality", () => {
60
+ const cases: { bare: string; state: PermissionState }[] = [
61
+ { bare: "git push", state: "ask" },
62
+ { bare: "git commit -m wip", state: "ask" },
63
+ { bare: "rm -rf build", state: "deny" },
64
+ { bare: "npm install pkg", state: "deny" },
65
+ { bare: "gh pr create", state: "ask" },
66
+ ];
67
+
68
+ for (const { bare, state } of cases) {
69
+ it(`wrapping "${bare}" in a cd prefix does not weaken its ${state} decision`, async () => {
70
+ const resolver = makeKeyedResolver([
71
+ { match: bare.split(" ")[0] ?? bare, state },
72
+ ]);
73
+
74
+ const bareDecision = await decide(bare, resolver);
75
+ const wrappedDecision = await decide(`cd /repo && ${bare}`, resolver);
76
+
77
+ expect(STRENGTH[wrappedDecision]).toBeGreaterThanOrEqual(
78
+ STRENGTH[bareDecision],
79
+ );
80
+ expect(wrappedDecision).toBe(state);
81
+ });
82
+ }
83
+ });
@@ -97,21 +97,48 @@ describe("resolveBashCommandCheck", () => {
97
97
  expect(result.matchedPattern).toBe("a *");
98
98
  });
99
99
 
100
- it("falls back to the whole command when no top-level commands are found", () => {
101
- const resolver = makeResolver(bashResult("ask", "( rm x )", "*"));
100
+ it("falls back to the whole command for a comment-only line (genuinely nothing to gate)", () => {
101
+ const resolver = makeResolver(bashResult("allow", "# just a comment", "*"));
102
102
 
103
- const result = resolveBashCommandCheck("( rm x )", [], undefined, resolver);
103
+ const result = resolveBashCommandCheck(
104
+ "# just a comment",
105
+ [],
106
+ undefined,
107
+ resolver,
108
+ );
104
109
 
105
- expect(result.state).toBe("ask");
106
- expect(result.commandContext).toBeUndefined();
110
+ expect(result.state).toBe("allow");
107
111
  expect(resolver.resolve).toHaveBeenCalledTimes(1);
108
112
  expect(resolver.resolve).toHaveBeenCalledWith(
109
113
  "bash",
110
- { command: "( rm x )" },
114
+ { command: "# just a comment" },
111
115
  undefined,
112
116
  );
113
117
  });
114
118
 
119
+ it("falls back to the whole command for an empty/whitespace-only command", () => {
120
+ const resolver = makeResolver(bashResult("allow", " ", "*"));
121
+
122
+ const result = resolveBashCommandCheck(" ", [], undefined, resolver);
123
+
124
+ expect(result.state).toBe("allow");
125
+ expect(resolver.resolve).toHaveBeenCalledTimes(1);
126
+ });
127
+
128
+ it("fails closed to ask when a non-empty command parses to zero command units", () => {
129
+ const resolver = makeResolver(bashResult("allow", "( rm x )", "*"));
130
+
131
+ const result = resolveBashCommandCheck("( rm x )", [], undefined, resolver);
132
+
133
+ // A permissive top-level '*' must NOT silently allow an unparseable command.
134
+ expect(result.state).toBe("ask");
135
+ expect(result.matchedPattern).toBe("<unparseable-bash-command>");
136
+ expect(result.command).toBe("( rm x )");
137
+ expect(result.commandContext).toBeUndefined();
138
+ // The synthetic ask is returned without consulting the resolver.
139
+ expect(resolver.resolve).not.toHaveBeenCalled();
140
+ });
141
+
115
142
  it("forwards the agent name to each sub-command check", () => {
116
143
  const resolver = makeResolver(bashResult("allow", "npm i"));
117
144
 
@@ -35,11 +35,13 @@ function makeSetup(opts?: { configIssues?: string[] }) {
35
35
  // Use a session-independent logger so assertions verify direct injection,
36
36
  // not reach-through to session.logger.
37
37
  const logger = makeLogger();
38
+ const audit = { writeSummary: vi.fn<(logger: unknown) => void>() };
38
39
  const handler = new SessionLifecycleHandler(
39
40
  session,
40
41
  resolver,
41
42
  serviceLifecycle,
42
43
  logger,
44
+ audit,
43
45
  );
44
46
  return {
45
47
  handler,
@@ -50,6 +52,7 @@ function makeSetup(opts?: { configIssues?: string[] }) {
50
52
  forwarding,
51
53
  configStore,
52
54
  serviceLifecycle,
55
+ audit,
53
56
  };
54
57
  }
55
58
 
@@ -209,4 +212,10 @@ describe("handleSessionShutdown", () => {
209
212
  await handler.handleSessionShutdown();
210
213
  expect(serviceLifecycle.teardown).toHaveBeenCalledOnce();
211
214
  });
215
+
216
+ it("writes the decision-audit summary to the logger", async () => {
217
+ const { handler, audit, logger } = makeSetup();
218
+ await handler.handleSessionShutdown();
219
+ expect(audit.writeSummary).toHaveBeenCalledWith(logger);
220
+ });
212
221
  });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The fail-closed boundary is the only tool_call handler the SDK sees.
3
+ *
4
+ * The SDK's emitToolCall (@earendil-works/pi-coding-agent dist/core/extensions/
5
+ * runner.js) awaits the registered handler with NO try/catch — unlike
6
+ * emitUserBash directly below it, which catches and continues. So a thrown
7
+ * gate would otherwise yield no block and the command would run ungated with
8
+ * no trace. This boundary must absorb the throw and fail closed.
9
+ */
10
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
+ import { describe, expect, it, vi } from "vitest";
12
+ import type { GateOutcome } from "#src/handlers/gates/types";
13
+ import { createFailClosedToolCall } from "#src/handlers/tool-call-boundary";
14
+
15
+ import { makeReporter } from "#test/helpers/gate-fixtures";
16
+ import { makeCtx, makeToolCallEvent } from "#test/helpers/handler-fixtures";
17
+
18
+ function makeAudit() {
19
+ return {
20
+ recordDecision: vi.fn<(action: "allow" | "block") => void>(),
21
+ recordError: vi.fn<() => void>(),
22
+ };
23
+ }
24
+
25
+ function makeTracer() {
26
+ return {
27
+ debug: vi.fn<(event: string, details?: Record<string, unknown>) => void>(),
28
+ };
29
+ }
30
+
31
+ function gateReturning(outcome: GateOutcome) {
32
+ return vi
33
+ .fn<(event: unknown, ctx: ExtensionContext) => Promise<GateOutcome>>()
34
+ .mockResolvedValue(outcome);
35
+ }
36
+
37
+ describe("createFailClosedToolCall", () => {
38
+ it("translates an allow outcome to the empty SDK shape", async () => {
39
+ const audit = makeAudit();
40
+ const reporter = makeReporter();
41
+ const boundary = createFailClosedToolCall(
42
+ gateReturning({ action: "allow" }),
43
+ reporter,
44
+ audit,
45
+ makeTracer(),
46
+ );
47
+
48
+ const result = await boundary(makeToolCallEvent("read"), makeCtx());
49
+
50
+ expect(result).toEqual({});
51
+ expect(audit.recordDecision).toHaveBeenCalledWith("allow");
52
+ expect(audit.recordError).not.toHaveBeenCalled();
53
+ expect(reporter.writeReviewLog).not.toHaveBeenCalled();
54
+ });
55
+
56
+ it("translates a block outcome to the SDK block shape with the reason", async () => {
57
+ const audit = makeAudit();
58
+ const reporter = makeReporter();
59
+ const boundary = createFailClosedToolCall(
60
+ gateReturning({ action: "block", reason: "denied by policy" }),
61
+ reporter,
62
+ audit,
63
+ makeTracer(),
64
+ );
65
+
66
+ const result = await boundary(makeToolCallEvent("read"), makeCtx());
67
+
68
+ expect(result).toEqual({ block: true, reason: "denied by policy" });
69
+ expect(audit.recordDecision).toHaveBeenCalledWith("block");
70
+ });
71
+
72
+ it("writes a per-call decision trace with the tool name and action", async () => {
73
+ const tracer = makeTracer();
74
+ const boundary = createFailClosedToolCall(
75
+ gateReturning({ action: "allow" }),
76
+ makeReporter(),
77
+ makeAudit(),
78
+ tracer,
79
+ );
80
+
81
+ await boundary(makeToolCallEvent("bash"), makeCtx());
82
+
83
+ expect(tracer.debug).toHaveBeenCalledWith(
84
+ "permission.decision",
85
+ expect.objectContaining({ toolName: "bash", action: "allow" }),
86
+ );
87
+ });
88
+
89
+ it("blocks fail-closed when the gate throws, recording an error and a review-log entry", async () => {
90
+ const audit = makeAudit();
91
+ const reporter = makeReporter();
92
+ const gate = vi
93
+ .fn<(event: unknown, ctx: ExtensionContext) => Promise<GateOutcome>>()
94
+ .mockRejectedValue(new Error("parser init failed"));
95
+ const boundary = createFailClosedToolCall(
96
+ gate,
97
+ reporter,
98
+ audit,
99
+ makeTracer(),
100
+ );
101
+
102
+ const event = makeToolCallEvent("bash", {
103
+ input: { command: "cd /repo && git push" },
104
+ });
105
+ const result = await boundary(event, makeCtx());
106
+
107
+ expect((result as { block?: true }).block).toBe(true);
108
+ expect(audit.recordError).toHaveBeenCalledTimes(1);
109
+ expect(audit.recordDecision).not.toHaveBeenCalled();
110
+ expect(reporter.writeReviewLog).toHaveBeenCalledWith(
111
+ "permission_request.blocked",
112
+ expect.objectContaining({
113
+ toolName: "bash",
114
+ command: "cd /repo && git push",
115
+ resolution: "gate_error",
116
+ error: "parser init failed",
117
+ }),
118
+ );
119
+ });
120
+
121
+ it("does not throw when the event is malformed and the gate throws", async () => {
122
+ const audit = makeAudit();
123
+ const reporter = makeReporter();
124
+ const gate = vi
125
+ .fn<(event: unknown, ctx: ExtensionContext) => Promise<GateOutcome>>()
126
+ .mockRejectedValue("non-error rejection");
127
+ const boundary = createFailClosedToolCall(
128
+ gate,
129
+ reporter,
130
+ audit,
131
+ makeTracer(),
132
+ );
133
+
134
+ const result = await boundary(undefined, makeCtx());
135
+
136
+ expect((result as { block?: true }).block).toBe(true);
137
+ expect(reporter.writeReviewLog).toHaveBeenCalledWith(
138
+ "permission_request.blocked",
139
+ expect.objectContaining({
140
+ resolution: "gate_error",
141
+ error: "non-error rejection",
142
+ }),
143
+ );
144
+ });
145
+ });