@gotgenes/pi-permission-system 5.5.0 → 5.6.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.
@@ -1,9 +1,14 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
-
3
- import { evaluateBashExternalDirectoryGate } from "../../../src/handlers/gates/bash-external-directory";
2
+ import { describeBashExternalDirectoryGate } from "../../../src/handlers/gates/bash-external-directory";
3
+ import type {
4
+ GateBypass,
5
+ GateDescriptor,
6
+ } from "../../../src/handlers/gates/descriptor";
7
+ import {
8
+ isGateBypass,
9
+ isGateDescriptor,
10
+ } from "../../../src/handlers/gates/descriptor";
4
11
  import type { ToolCallContext } from "../../../src/handlers/gates/types";
5
- import type { HandlerDeps } from "../../../src/handlers/types";
6
- import type { PermissionEventBus } from "../../../src/permission-events";
7
12
  import type { PermissionCheckResult } from "../../../src/types";
8
13
 
9
14
  // ── helpers ────────────────────────────────────────────────────────────────
@@ -32,216 +37,156 @@ function makeCheckResult(
32
37
  };
33
38
  }
34
39
 
35
- function makeEvents(): PermissionEventBus {
36
- return { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) };
37
- }
38
-
39
- function makeRuntime(
40
- overrides: Record<string, unknown> = {},
41
- ): HandlerDeps["runtime"] {
42
- return {
43
- config: { debugLog: false, permissionReviewLog: true, yoloMode: false },
44
- runtimeContext: {} as HandlerDeps["runtime"]["runtimeContext"],
45
- permissionManager: {
46
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
47
- },
48
- sessionRules: {
49
- approve: vi.fn(),
50
- getRuleset: vi.fn().mockReturnValue([]),
51
- clear: vi.fn(),
52
- },
53
- writeReviewLog: vi.fn(),
54
- ...overrides,
55
- } as unknown as HandlerDeps["runtime"];
56
- }
57
-
58
- function makeDeps(overrides: Record<string, unknown> = {}): HandlerDeps {
59
- const { runtime: runtimeOverrides, events, ...rest } = overrides;
60
- return {
61
- runtime: makeRuntime(runtimeOverrides as Record<string, unknown>),
62
- events: events ?? makeEvents(),
63
- canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
64
- promptPermission: vi
65
- .fn()
66
- .mockResolvedValue({ approved: true, state: "approved" }),
67
- ...rest,
68
- } as unknown as HandlerDeps;
69
- }
70
-
71
40
  // ── tests ──────────────────────────────────────────────────────────────────
72
41
 
73
- describe("evaluateBashExternalDirectoryGate", () => {
42
+ describe("describeBashExternalDirectoryGate", () => {
74
43
  it("returns null when tool is not bash", async () => {
75
- const tcc = makeTcc({ toolName: "read" });
76
- const result = await evaluateBashExternalDirectoryGate(tcc, makeDeps());
44
+ const result = await describeBashExternalDirectoryGate(
45
+ makeTcc({ toolName: "read" }),
46
+ vi.fn().mockReturnValue(makeCheckResult("ask")),
47
+ vi.fn().mockReturnValue([]),
48
+ );
77
49
  expect(result).toBeNull();
78
50
  });
79
51
 
80
52
  it("returns null when no CWD", async () => {
81
- const tcc = makeTcc({ cwd: undefined });
82
- const result = await evaluateBashExternalDirectoryGate(tcc, makeDeps());
53
+ const result = await describeBashExternalDirectoryGate(
54
+ makeTcc({ cwd: undefined }),
55
+ vi.fn().mockReturnValue(makeCheckResult("ask")),
56
+ vi.fn().mockReturnValue([]),
57
+ );
83
58
  expect(result).toBeNull();
84
59
  });
85
60
 
86
61
  it("returns null when command has no external paths", async () => {
87
- const tcc = makeTcc({ input: { command: "ls -la" } });
88
- const result = await evaluateBashExternalDirectoryGate(tcc, makeDeps());
62
+ const result = await describeBashExternalDirectoryGate(
63
+ makeTcc({ input: { command: "ls -la" } }),
64
+ vi.fn().mockReturnValue(makeCheckResult("ask")),
65
+ vi.fn().mockReturnValue([]),
66
+ );
89
67
  expect(result).toBeNull();
90
68
  });
91
69
 
92
- it("returns null and logs when all external paths are session-covered", async () => {
93
- const writeReviewLog = vi.fn();
94
- const deps = makeDeps({
95
- runtime: {
96
- permissionManager: {
97
- checkPermission: vi
98
- .fn()
99
- .mockReturnValue(makeCheckResult("allow", { source: "session" })),
100
- },
101
- writeReviewLog,
102
- },
70
+ it("returns GateBypass when all external paths are session-covered", async () => {
71
+ const checkPermission = vi
72
+ .fn()
73
+ .mockReturnValue(makeCheckResult("allow", { source: "session" }));
74
+ const result = await describeBashExternalDirectoryGate(
75
+ makeTcc(),
76
+ checkPermission,
77
+ vi.fn().mockReturnValue([]),
78
+ );
79
+ expect(result).not.toBeNull();
80
+ expect(isGateBypass(result)).toBe(true);
81
+ const bypass = result as GateBypass;
82
+ expect(bypass.action).toBe("allow");
83
+ expect(bypass.log).toMatchObject({
84
+ event: "permission_request.session_approved",
85
+ details: expect.objectContaining({ resolution: "session_approved" }),
103
86
  });
104
- const tcc = makeTcc();
105
- const result = await evaluateBashExternalDirectoryGate(tcc, deps);
106
- expect(result).toBeNull();
107
- expect(writeReviewLog).toHaveBeenCalledWith(
108
- "permission_request.session_approved",
109
- expect.objectContaining({ resolution: "session_approved" }),
87
+ });
88
+
89
+ it("returns GateDescriptor with multi-pattern sessionApproval for uncovered paths", async () => {
90
+ const checkPermission = vi.fn().mockReturnValue(makeCheckResult("ask"));
91
+ const result = await describeBashExternalDirectoryGate(
92
+ makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
93
+ checkPermission,
94
+ vi.fn().mockReturnValue([]),
110
95
  );
96
+ expect(isGateDescriptor(result)).toBe(true);
97
+ const desc = result as GateDescriptor;
98
+ expect(desc.sessionApproval).toBeDefined();
99
+ expect(desc.sessionApproval).toHaveProperty("patterns");
100
+ const patterns = (desc.sessionApproval as { patterns: string[] }).patterns;
101
+ expect(patterns.length).toBeGreaterThan(0);
111
102
  });
112
103
 
113
- it("blocks when policy is deny", async () => {
114
- const deps = makeDeps({
115
- runtime: {
116
- permissionManager: {
117
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("deny")),
118
- },
119
- },
120
- });
121
- const tcc = makeTcc();
122
- const result = await evaluateBashExternalDirectoryGate(tcc, deps);
123
- expect(result).toMatchObject({ action: "block" });
104
+ it("uses config-level checkPermission for the policy state", async () => {
105
+ const checkPermission = vi
106
+ .fn()
107
+ .mockImplementation((surface: string, input: Record<string, unknown>) => {
108
+ // Path-specific check returns session for coverage filtering
109
+ if (input.path) return makeCheckResult("allow", { source: "special" });
110
+ // Config-level check (no path) returns deny
111
+ return makeCheckResult("deny");
112
+ });
113
+ const result = await describeBashExternalDirectoryGate(
114
+ makeTcc(),
115
+ checkPermission,
116
+ vi.fn().mockReturnValue([]),
117
+ );
118
+ expect(isGateDescriptor(result)).toBe(true);
119
+ // The descriptor should carry the deny state from the config-level check
120
+ // (it will be checked as preCheck by the runner)
121
+ const desc = result as GateDescriptor;
122
+ expect(desc.preCheck?.state).toBe("deny");
124
123
  });
125
124
 
126
- it("allows without recording session rules when user approves once", async () => {
127
- const sessionRules = {
128
- approve: vi.fn(),
129
- getRuleset: vi.fn().mockReturnValue([]),
130
- clear: vi.fn(),
131
- };
132
- const deps = makeDeps({
133
- runtime: {
134
- permissionManager: {
135
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
136
- },
137
- sessionRules,
138
- },
139
- promptPermission: vi
140
- .fn()
141
- .mockResolvedValue({ approved: true, state: "approved" }),
142
- });
143
- const tcc = makeTcc();
144
- const result = await evaluateBashExternalDirectoryGate(tcc, deps);
145
- expect(result).toEqual({ action: "allow" });
146
- expect(sessionRules.approve).not.toHaveBeenCalled();
125
+ it("descriptor surface is 'external_directory'", async () => {
126
+ const result = await describeBashExternalDirectoryGate(
127
+ makeTcc(),
128
+ vi.fn().mockReturnValue(makeCheckResult("ask")),
129
+ vi.fn().mockReturnValue([]),
130
+ );
131
+ const desc = result as GateDescriptor;
132
+ expect(desc.surface).toBe("external_directory");
147
133
  });
148
134
 
149
- it("records one session rule per uncovered path on approved_for_session", async () => {
150
- const sessionRules = {
151
- approve: vi.fn(),
152
- getRuleset: vi.fn().mockReturnValue([]),
153
- clear: vi.fn(),
154
- };
155
- const deps = makeDeps({
156
- runtime: {
157
- permissionManager: {
158
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
159
- },
160
- sessionRules,
161
- },
162
- promptPermission: vi
163
- .fn()
164
- .mockResolvedValue({ approved: true, state: "approved_for_session" }),
165
- });
166
- // Command referencing two external paths
167
- const tcc = makeTcc({
168
- input: {
169
- command: "diff /outside/a.ts /outside/b.ts",
170
- },
171
- });
172
- const result = await evaluateBashExternalDirectoryGate(tcc, deps);
173
- expect(result).toEqual({ action: "allow" });
174
- // Each uncovered path gets its own session rule
175
- expect(sessionRules.approve).toHaveBeenCalledTimes(2);
176
- for (const call of (sessionRules.approve as ReturnType<typeof vi.fn>).mock
177
- .calls) {
178
- expect(call[0]).toBe("external_directory");
179
- }
135
+ it("descriptor decision surface is 'external_directory'", async () => {
136
+ const result = await describeBashExternalDirectoryGate(
137
+ makeTcc(),
138
+ vi.fn().mockReturnValue(makeCheckResult("ask")),
139
+ vi.fn().mockReturnValue([]),
140
+ );
141
+ const desc = result as GateDescriptor;
142
+ expect(desc.decision.surface).toBe("external_directory");
180
143
  });
181
144
 
182
- it("blocks when user denies", async () => {
183
- const deps = makeDeps({
184
- runtime: {
185
- permissionManager: {
186
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
187
- },
188
- },
189
- promptPermission: vi
190
- .fn()
191
- .mockResolvedValue({ approved: false, state: "denied" }),
192
- });
193
- const tcc = makeTcc();
194
- const result = await evaluateBashExternalDirectoryGate(tcc, deps);
195
- expect(result).toMatchObject({ action: "block" });
145
+ it("messages contain the command", async () => {
146
+ const result = await describeBashExternalDirectoryGate(
147
+ makeTcc({ input: { command: "cat /outside/file.ts" } }),
148
+ vi.fn().mockReturnValue(makeCheckResult("ask")),
149
+ vi.fn().mockReturnValue([]),
150
+ );
151
+ const desc = result as GateDescriptor;
152
+ expect(desc.messages.denyReason).toContain("cat /outside/file.ts");
153
+ expect(desc.messages.unavailableReason).toContain("cat /outside/file.ts");
196
154
  });
197
155
 
198
- it("blocks when no UI available", async () => {
199
- const deps = makeDeps({
200
- runtime: {
201
- permissionManager: {
202
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
203
- },
204
- },
205
- canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
156
+ it("promptDetails includes command and tool_call source", async () => {
157
+ const result = await describeBashExternalDirectoryGate(
158
+ makeTcc({ agentName: "agent-1", toolCallId: "tc-5" }),
159
+ vi.fn().mockReturnValue(makeCheckResult("ask")),
160
+ vi.fn().mockReturnValue([]),
161
+ );
162
+ const desc = result as GateDescriptor;
163
+ expect(desc.promptDetails).toMatchObject({
164
+ source: "tool_call",
165
+ agentName: "agent-1",
166
+ toolCallId: "tc-5",
167
+ toolName: "bash",
168
+ command: "cat /outside/project/file.ts",
206
169
  });
207
- const tcc = makeTcc();
208
- const result = await evaluateBashExternalDirectoryGate(tcc, deps);
209
- expect(result).toMatchObject({ action: "block" });
210
170
  });
211
171
 
212
- it("only prompts about uncovered paths when some are session-covered", async () => {
213
- // First call (for getRuleset path filter): session covers /outside/a.ts
214
- // Second call (for config-level policy): returns ask
172
+ it("only includes uncovered paths when some are session-covered", async () => {
215
173
  const checkPermission = vi
216
174
  .fn()
217
- .mockImplementation(
218
- (
219
- surface: string,
220
- input: Record<string, unknown>,
221
- ): PermissionCheckResult => {
222
- if (
223
- surface === "external_directory" &&
224
- input.path === "/outside/a.ts"
225
- ) {
226
- return makeCheckResult("allow", { source: "session" });
227
- }
228
- return makeCheckResult("ask");
229
- },
230
- );
231
- const deps = makeDeps({
232
- runtime: {
233
- permissionManager: { checkPermission },
234
- },
235
- promptPermission: vi
236
- .fn()
237
- .mockResolvedValue({ approved: true, state: "approved" }),
238
- });
239
- const tcc = makeTcc({
240
- input: { command: "diff /outside/a.ts /outside/b.ts" },
241
- });
242
- const result = await evaluateBashExternalDirectoryGate(tcc, deps);
243
- expect(result).toEqual({ action: "allow" });
244
- // The prompt should have been called (for uncovered /outside/b.ts)
245
- expect(deps.promptPermission).toHaveBeenCalled();
175
+ .mockImplementation((surface: string, input: Record<string, unknown>) => {
176
+ if (input.path === "/outside/a.ts") {
177
+ return makeCheckResult("allow", { source: "session" });
178
+ }
179
+ return makeCheckResult("ask");
180
+ });
181
+ const result = await describeBashExternalDirectoryGate(
182
+ makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
183
+ checkPermission,
184
+ vi.fn().mockReturnValue([]),
185
+ );
186
+ expect(isGateDescriptor(result)).toBe(true);
187
+ const desc = result as GateDescriptor;
188
+ // Should have patterns only for the uncovered path
189
+ const patterns = (desc.sessionApproval as { patterns: string[] }).patterns;
190
+ expect(patterns.length).toBe(1);
246
191
  });
247
192
  });