@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.
- package/CHANGELOG.md +30 -0
- package/README.md +11 -0
- package/package.json +1 -1
- package/src/async-cache.ts +21 -0
- package/src/config-loader.ts +35 -0
- package/src/decision-audit.ts +75 -0
- package/src/handlers/gates/bash-command.ts +35 -3
- package/src/handlers/gates/bash-program.ts +5 -6
- package/src/handlers/lifecycle.ts +4 -0
- package/src/handlers/permission-gate-handler.ts +4 -7
- package/src/handlers/tool-call-boundary.ts +91 -0
- package/src/index.ts +13 -1
- package/test/async-cache.test.ts +48 -0
- package/test/config-loader.test.ts +22 -1
- package/test/decision-audit.test.ts +72 -0
- package/test/detect-permissive-bash-fallback.test.ts +56 -0
- package/test/handlers/external-directory-integration.test.ts +24 -20
- package/test/handlers/external-directory-session-dedup.test.ts +4 -4
- package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
- package/test/handlers/gates/bash-command.test.ts +33 -6
- package/test/handlers/lifecycle.test.ts +9 -0
- package/test/handlers/tool-call-boundary.test.ts +145 -0
- package/test/handlers/tool-call.test.ts +18 -18
|
@@ -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({
|
|
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({
|
|
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
|
|
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
|
|
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(
|
|
299
|
-
|
|
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
|
|
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
|
|
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
|
|
455
|
-
expect(result.reason).toContain(
|
|
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({
|
|
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
|
|
101
|
-
const resolver = makeResolver(bashResult("
|
|
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(
|
|
103
|
+
const result = resolveBashCommandCheck(
|
|
104
|
+
"# just a comment",
|
|
105
|
+
[],
|
|
106
|
+
undefined,
|
|
107
|
+
resolver,
|
|
108
|
+
);
|
|
104
109
|
|
|
105
|
-
expect(result.state).toBe("
|
|
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: "
|
|
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
|
+
});
|