@gotgenes/pi-permission-system 10.0.0 → 10.2.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 (68) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/agent-prep-session.ts +28 -0
  5. package/src/decision-reporter.ts +41 -0
  6. package/src/denial-messages.ts +11 -0
  7. package/src/forwarded-permissions/permission-forwarder.ts +549 -0
  8. package/src/forwarding-manager.ts +3 -7
  9. package/src/gate-handler-session.ts +13 -0
  10. package/src/gate-prompter.ts +14 -0
  11. package/src/handlers/before-agent-start.ts +2 -3
  12. package/src/handlers/gates/bash-command.ts +4 -18
  13. package/src/handlers/gates/bash-external-directory.ts +3 -15
  14. package/src/handlers/gates/bash-path.ts +3 -16
  15. package/src/handlers/gates/descriptor.ts +0 -28
  16. package/src/handlers/gates/path.ts +3 -15
  17. package/src/handlers/gates/runner.ts +142 -105
  18. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  19. package/src/handlers/gates/skill-input.ts +44 -0
  20. package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
  21. package/src/handlers/lifecycle.ts +9 -9
  22. package/src/handlers/permission-gate-handler.ts +34 -238
  23. package/src/index.ts +53 -69
  24. package/src/mcp-targets.ts +56 -46
  25. package/src/permission-manager.ts +69 -3
  26. package/src/permission-prompter.ts +7 -58
  27. package/src/permission-resolver.ts +17 -0
  28. package/src/permission-session.ts +83 -27
  29. package/src/permissions-service.ts +53 -0
  30. package/src/runtime.ts +1 -37
  31. package/src/service-lifecycle.ts +49 -0
  32. package/src/session-approval-recorder.ts +6 -0
  33. package/src/session-lifecycle-session.ts +24 -0
  34. package/src/tool-input-preview.ts +0 -62
  35. package/src/tool-input-prompt-formatters.ts +63 -0
  36. package/src/tool-preview-formatter.ts +6 -4
  37. package/test/decision-reporter.test.ts +112 -0
  38. package/test/denial-messages.test.ts +62 -0
  39. package/test/forwarding-manager.test.ts +26 -44
  40. package/test/handlers/before-agent-start.test.ts +45 -21
  41. package/test/handlers/external-directory-integration.test.ts +83 -114
  42. package/test/handlers/external-directory-session-dedup.test.ts +102 -55
  43. package/test/handlers/gates/bash-command.test.ts +49 -90
  44. package/test/handlers/gates/bash-external-directory.test.ts +54 -95
  45. package/test/handlers/gates/bash-path.test.ts +54 -157
  46. package/test/handlers/gates/path.test.ts +38 -105
  47. package/test/handlers/gates/runner.test.ts +151 -186
  48. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  49. package/test/handlers/gates/skill-input.test.ts +128 -0
  50. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
  51. package/test/handlers/input.test.ts +1 -2
  52. package/test/handlers/lifecycle.test.ts +49 -33
  53. package/test/handlers/tool-call-events.test.ts +1 -1
  54. package/test/handlers/tool-call.test.ts +44 -153
  55. package/test/helpers/gate-fixtures.ts +212 -17
  56. package/test/helpers/handler-fixtures.ts +226 -29
  57. package/test/mcp-targets.test.ts +55 -0
  58. package/test/permission-forwarder.test.ts +295 -0
  59. package/test/permission-forwarding.test.ts +0 -282
  60. package/test/permission-manager-unified.test.ts +159 -1
  61. package/test/permission-prompter.test.ts +33 -44
  62. package/test/permission-session.test.ts +211 -105
  63. package/test/permissions-service.test.ts +151 -0
  64. package/test/runtime.test.ts +2 -86
  65. package/test/service-lifecycle.test.ts +162 -0
  66. package/test/tool-input-preview.test.ts +0 -111
  67. package/test/tool-input-prompt-formatters.test.ts +115 -0
  68. package/src/forwarded-permissions/polling.ts +0 -411
@@ -2,20 +2,23 @@ import { describe, expect, it, vi } from "vitest";
2
2
 
3
3
  import type { DenialContext } from "#src/denial-messages";
4
4
  import { EXTENSION_TAG } from "#src/denial-messages";
5
- import type { GateDescriptor } from "#src/handlers/gates/descriptor";
6
- import { runGateCheck } from "#src/handlers/gates/runner";
5
+ import type { GateBypass } from "#src/handlers/gates/descriptor";
7
6
  import { SessionApproval } from "#src/session-approval";
8
- import { makeDescriptor, makeRunnerDeps } from "#test/helpers/gate-fixtures";
7
+ import {
8
+ makeDenialDescriptor,
9
+ makeDescriptor,
10
+ makeGateRunner,
11
+ } from "#test/helpers/gate-fixtures";
9
12
  import { makeCheckResult } from "#test/helpers/handler-fixtures";
10
13
 
11
- // ── tests ──────────────────────────────────────────────────────────────────
14
+ // ── GateRunner — descriptor path ───────────────────────────────────────────
12
15
 
13
- describe("runGateCheck", () => {
16
+ describe("GateRunner — descriptor path", () => {
14
17
  it("returns allow and emits policy_allow when policy is allow", async () => {
15
- const deps = makeRunnerDeps();
16
- const result = await runGateCheck(makeDescriptor(), null, "tc-1", deps);
18
+ const { runner, deps } = makeGateRunner();
19
+ const result = await runner.run(makeDescriptor(), null, "tc-1");
17
20
  expect(result).toEqual({ action: "allow" });
18
- expect(deps.emitDecision).toHaveBeenCalledWith(
21
+ expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
19
22
  expect.objectContaining({
20
23
  surface: "read",
21
24
  value: "read",
@@ -26,36 +29,31 @@ describe("runGateCheck", () => {
26
29
  });
27
30
 
28
31
  it("returns block and emits policy_deny when policy is deny", async () => {
29
- const deps = makeRunnerDeps({
30
- checkPermission: vi
31
- .fn()
32
- .mockReturnValue(
33
- makeCheckResult({ state: "deny", matchedPattern: "*" }),
34
- ),
32
+ const { runner, deps } = makeGateRunner({
33
+ resolveResult: makeCheckResult({ state: "deny", matchedPattern: "*" }),
35
34
  });
36
- const result = await runGateCheck(makeDescriptor(), null, "tc-1", deps);
35
+ const result = await runner.run(makeDescriptor(), null, "tc-1");
37
36
  expect(result).toMatchObject({ action: "block" });
38
- expect(deps.emitDecision).toHaveBeenCalledWith(
37
+ expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
39
38
  expect.objectContaining({
40
39
  result: "deny",
41
40
  resolution: "policy_deny",
42
41
  }),
43
42
  );
44
- expect(deps.writeReviewLog).toHaveBeenCalledWith(
43
+ expect(deps.reporter.writeReviewLog).toHaveBeenCalledWith(
45
44
  "permission_request.blocked",
46
45
  expect.objectContaining({ resolution: "policy_denied" }),
47
46
  );
48
47
  });
49
48
 
50
49
  it("returns allow and emits session_approved on session hit", async () => {
51
- const deps = makeRunnerDeps({
52
- checkPermission: vi
53
- .fn()
54
- .mockReturnValue(
55
- makeCheckResult({ source: "session", matchedPattern: "git *" }),
56
- ),
50
+ const { runner, deps } = makeGateRunner({
51
+ resolveResult: makeCheckResult({
52
+ source: "session",
53
+ matchedPattern: "git *",
54
+ }),
57
55
  });
58
- const result = await runGateCheck(
56
+ const result = await runner.run(
59
57
  makeDescriptor({
60
58
  surface: "bash",
61
59
  input: { command: "git status" },
@@ -63,17 +61,16 @@ describe("runGateCheck", () => {
63
61
  }),
64
62
  null,
65
63
  "tc-1",
66
- deps,
67
64
  );
68
65
  expect(result).toEqual({ action: "allow" });
69
- expect(deps.writeReviewLog).toHaveBeenCalledWith(
66
+ expect(deps.reporter.writeReviewLog).toHaveBeenCalledWith(
70
67
  "permission_request.session_approved",
71
68
  expect.objectContaining({
72
69
  resolution: "session_approved",
73
70
  sessionApprovalPattern: "git *",
74
71
  }),
75
72
  );
76
- expect(deps.emitDecision).toHaveBeenCalledWith(
73
+ expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
77
74
  expect.objectContaining({
78
75
  resolution: "session_approved",
79
76
  matchedPattern: "git *",
@@ -82,19 +79,15 @@ describe("runGateCheck", () => {
82
79
  });
83
80
 
84
81
  it("returns allow and emits user_approved when ask + user approves", async () => {
85
- const deps = makeRunnerDeps({
86
- checkPermission: vi
87
- .fn()
88
- .mockReturnValue(
89
- makeCheckResult({ state: "ask", matchedPattern: "*" }),
90
- ),
82
+ const { runner, deps } = makeGateRunner({
83
+ resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
91
84
  promptPermission: vi
92
85
  .fn()
93
86
  .mockResolvedValue({ approved: true, state: "approved" }),
94
87
  });
95
- const result = await runGateCheck(makeDescriptor(), null, "tc-1", deps);
88
+ const result = await runner.run(makeDescriptor(), null, "tc-1");
96
89
  expect(result).toEqual({ action: "allow" });
97
- expect(deps.emitDecision).toHaveBeenCalledWith(
90
+ expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
98
91
  expect.objectContaining({
99
92
  result: "allow",
100
93
  resolution: "user_approved",
@@ -103,12 +96,8 @@ describe("runGateCheck", () => {
103
96
  });
104
97
 
105
98
  it("returns allow, emits user_approved_for_session, and records session rule on approved_for_session", async () => {
106
- const deps = makeRunnerDeps({
107
- checkPermission: vi
108
- .fn()
109
- .mockReturnValue(
110
- makeCheckResult({ state: "ask", matchedPattern: "*" }),
111
- ),
99
+ const { runner, deps } = makeGateRunner({
100
+ resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
112
101
  promptPermission: vi
113
102
  .fn()
114
103
  .mockResolvedValue({ approved: true, state: "approved_for_session" }),
@@ -116,9 +105,9 @@ describe("runGateCheck", () => {
116
105
  const descriptor = makeDescriptor({
117
106
  sessionApproval: SessionApproval.single("read", "*"),
118
107
  });
119
- const result = await runGateCheck(descriptor, null, "tc-1", deps);
108
+ const result = await runner.run(descriptor, null, "tc-1");
120
109
  expect(result).toEqual({ action: "allow" });
121
- expect(deps.emitDecision).toHaveBeenCalledWith(
110
+ expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
122
111
  expect.objectContaining({
123
112
  resolution: "user_approved_for_session",
124
113
  }),
@@ -129,12 +118,8 @@ describe("runGateCheck", () => {
129
118
  });
130
119
 
131
120
  it("calls recordSessionApproval once with the full SessionApproval when sessionApproval has multiple patterns", async () => {
132
- const deps = makeRunnerDeps({
133
- checkPermission: vi
134
- .fn()
135
- .mockReturnValue(
136
- makeCheckResult({ state: "ask", matchedPattern: "*" }),
137
- ),
121
+ const { runner, deps } = makeGateRunner({
122
+ resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
138
123
  promptPermission: vi
139
124
  .fn()
140
125
  .mockResolvedValue({ approved: true, state: "approved_for_session" }),
@@ -144,26 +129,22 @@ describe("runGateCheck", () => {
144
129
  "/outside/b/*",
145
130
  ]);
146
131
  const descriptor = makeDescriptor({ sessionApproval: approval });
147
- const result = await runGateCheck(descriptor, null, "tc-1", deps);
132
+ const result = await runner.run(descriptor, null, "tc-1");
148
133
  expect(result).toEqual({ action: "allow" });
149
134
  expect(deps.recordSessionApproval).toHaveBeenCalledTimes(1);
150
135
  expect(deps.recordSessionApproval).toHaveBeenCalledWith(approval);
151
136
  });
152
137
 
153
138
  it("returns block and emits user_denied when ask + user denies", async () => {
154
- const deps = makeRunnerDeps({
155
- checkPermission: vi
156
- .fn()
157
- .mockReturnValue(
158
- makeCheckResult({ state: "ask", matchedPattern: "*" }),
159
- ),
139
+ const { runner, deps } = makeGateRunner({
140
+ resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
160
141
  promptPermission: vi
161
142
  .fn()
162
143
  .mockResolvedValue({ approved: false, state: "denied" }),
163
144
  });
164
- const result = await runGateCheck(makeDescriptor(), null, "tc-1", deps);
145
+ const result = await runner.run(makeDescriptor(), null, "tc-1");
165
146
  expect(result).toMatchObject({ action: "block" });
166
- expect(deps.emitDecision).toHaveBeenCalledWith(
147
+ expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
167
148
  expect.objectContaining({
168
149
  result: "deny",
169
150
  resolution: "user_denied",
@@ -172,17 +153,13 @@ describe("runGateCheck", () => {
172
153
  });
173
154
 
174
155
  it("returns block and emits confirmation_unavailable when ask + no UI", async () => {
175
- const deps = makeRunnerDeps({
176
- checkPermission: vi
177
- .fn()
178
- .mockReturnValue(
179
- makeCheckResult({ state: "ask", matchedPattern: "*" }),
180
- ),
156
+ const { runner, deps } = makeGateRunner({
157
+ resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
181
158
  canConfirm: vi.fn().mockReturnValue(false),
182
159
  });
183
- const result = await runGateCheck(makeDescriptor(), null, "tc-1", deps);
160
+ const result = await runner.run(makeDescriptor(), null, "tc-1");
184
161
  expect(result).toMatchObject({ action: "block" });
185
- expect(deps.emitDecision).toHaveBeenCalledWith(
162
+ expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
186
163
  expect.objectContaining({
187
164
  result: "deny",
188
165
  resolution: "confirmation_unavailable",
@@ -191,73 +168,59 @@ describe("runGateCheck", () => {
191
168
  });
192
169
 
193
170
  it("emits auto_approved resolution when decision has autoApproved flag", async () => {
194
- const deps = makeRunnerDeps({
195
- checkPermission: vi
196
- .fn()
197
- .mockReturnValue(
198
- makeCheckResult({ state: "ask", matchedPattern: "*" }),
199
- ),
171
+ const { runner, deps } = makeGateRunner({
172
+ resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
200
173
  promptPermission: vi.fn().mockResolvedValue({
201
174
  approved: true,
202
175
  state: "approved",
203
176
  autoApproved: true,
204
177
  }),
205
178
  });
206
- const result = await runGateCheck(makeDescriptor(), null, "tc-1", deps);
179
+ const result = await runner.run(makeDescriptor(), null, "tc-1");
207
180
  expect(result).toEqual({ action: "allow" });
208
- expect(deps.emitDecision).toHaveBeenCalledWith(
181
+ expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
209
182
  expect.objectContaining({
210
183
  resolution: "auto_approved",
211
184
  }),
212
185
  );
213
186
  });
214
187
 
215
- it("uses preResolved.state instead of calling checkPermission", async () => {
216
- const deps = makeRunnerDeps();
188
+ it("uses preResolved.state instead of calling resolve", async () => {
189
+ const { runner, deps } = makeGateRunner();
217
190
  const descriptor = makeDescriptor({
218
191
  preResolved: { state: "deny" },
219
192
  });
220
- const result = await runGateCheck(descriptor, null, "tc-1", deps);
193
+ const result = await runner.run(descriptor, null, "tc-1");
221
194
  expect(result).toMatchObject({ action: "block" });
222
- expect(deps.checkPermission).not.toHaveBeenCalled();
223
- expect(deps.emitDecision).toHaveBeenCalledWith(
195
+ expect(deps.resolve).not.toHaveBeenCalled();
196
+ expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
224
197
  expect.objectContaining({
225
198
  resolution: "policy_deny",
226
199
  }),
227
200
  );
228
201
  });
229
202
 
230
- it("uses preResolved.state allow without calling checkPermission", async () => {
231
- const deps = makeRunnerDeps();
203
+ it("uses preResolved.state allow without calling resolve", async () => {
204
+ const { runner, deps } = makeGateRunner();
232
205
  const descriptor = makeDescriptor({
233
206
  preResolved: { state: "allow" },
234
207
  });
235
- const result = await runGateCheck(descriptor, null, "tc-1", deps);
208
+ const result = await runner.run(descriptor, null, "tc-1");
236
209
  expect(result).toEqual({ action: "allow" });
237
- expect(deps.checkPermission).not.toHaveBeenCalled();
238
- expect(deps.emitDecision).toHaveBeenCalledWith(
210
+ expect(deps.resolve).not.toHaveBeenCalled();
211
+ expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
239
212
  expect.objectContaining({
240
213
  resolution: "policy_allow",
241
214
  }),
242
215
  );
243
216
  });
244
217
 
245
- it("passes agentName to checkPermission and decision event", async () => {
246
- const deps = makeRunnerDeps();
247
- const result = await runGateCheck(
248
- makeDescriptor(),
249
- "test-agent",
250
- "tc-1",
251
- deps,
252
- );
218
+ it("passes agentName to resolve and decision event", async () => {
219
+ const { runner, deps } = makeGateRunner();
220
+ const result = await runner.run(makeDescriptor(), "test-agent", "tc-1");
253
221
  expect(result).toEqual({ action: "allow" });
254
- expect(deps.checkPermission).toHaveBeenCalledWith(
255
- "read",
256
- {},
257
- "test-agent",
258
- [],
259
- );
260
- expect(deps.emitDecision).toHaveBeenCalledWith(
222
+ expect(deps.resolve).toHaveBeenCalledWith("read", {}, "test-agent");
223
+ expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
261
224
  expect.objectContaining({
262
225
  agentName: "test-agent",
263
226
  }),
@@ -265,36 +228,28 @@ describe("runGateCheck", () => {
265
228
  });
266
229
 
267
230
  it("passes requestId from toolCallId to promptPermission", async () => {
268
- const deps = makeRunnerDeps({
269
- checkPermission: vi
270
- .fn()
271
- .mockReturnValue(
272
- makeCheckResult({ state: "ask", matchedPattern: "*" }),
273
- ),
231
+ const { runner, deps } = makeGateRunner({
232
+ resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
274
233
  });
275
- await runGateCheck(makeDescriptor(), null, "tc-42", deps);
234
+ await runner.run(makeDescriptor(), null, "tc-42");
276
235
  expect(deps.promptPermission).toHaveBeenCalledWith(
277
236
  expect.objectContaining({ requestId: "tc-42" }),
278
237
  );
279
238
  });
280
239
 
281
240
  it("does not call recordSessionApproval when user approves once (no sessionApproval)", async () => {
282
- const deps = makeRunnerDeps({
283
- checkPermission: vi
284
- .fn()
285
- .mockReturnValue(
286
- makeCheckResult({ state: "ask", matchedPattern: "*" }),
287
- ),
241
+ const { runner, deps } = makeGateRunner({
242
+ resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
288
243
  promptPermission: vi
289
244
  .fn()
290
245
  .mockResolvedValue({ approved: true, state: "approved" }),
291
246
  });
292
- await runGateCheck(makeDescriptor(), null, "tc-1", deps);
247
+ await runner.run(makeDescriptor(), null, "tc-1");
293
248
  expect(deps.recordSessionApproval).not.toHaveBeenCalled();
294
249
  });
295
250
 
296
- it("uses preCheck result directly instead of calling checkPermission", async () => {
297
- const deps = makeRunnerDeps();
251
+ it("uses preCheck result directly instead of calling resolve", async () => {
252
+ const { runner, deps } = makeGateRunner();
298
253
  const descriptor = makeDescriptor({
299
254
  preCheck: makeCheckResult({
300
255
  state: "deny",
@@ -302,10 +257,10 @@ describe("runGateCheck", () => {
302
257
  matchedPattern: "rm *",
303
258
  }),
304
259
  });
305
- const result = await runGateCheck(descriptor, null, "tc-1", deps);
260
+ const result = await runner.run(descriptor, null, "tc-1");
306
261
  expect(result).toMatchObject({ action: "block" });
307
- expect(deps.checkPermission).not.toHaveBeenCalled();
308
- expect(deps.emitDecision).toHaveBeenCalledWith(
262
+ expect(deps.resolve).not.toHaveBeenCalled();
263
+ expect(deps.reporter.emitDecision).toHaveBeenCalledWith(
309
264
  expect.objectContaining({
310
265
  resolution: "policy_deny",
311
266
  origin: "global",
@@ -315,68 +270,31 @@ describe("runGateCheck", () => {
315
270
  });
316
271
 
317
272
  it("does not call recordSessionApproval when user approves for session but no sessionApproval on descriptor", async () => {
318
- const deps = makeRunnerDeps({
319
- checkPermission: vi
320
- .fn()
321
- .mockReturnValue(
322
- makeCheckResult({ state: "ask", matchedPattern: "*" }),
323
- ),
273
+ const { runner, deps } = makeGateRunner({
274
+ resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
324
275
  promptPermission: vi
325
276
  .fn()
326
277
  .mockResolvedValue({ approved: true, state: "approved_for_session" }),
327
278
  });
328
279
  // No sessionApproval on descriptor
329
- await runGateCheck(makeDescriptor(), null, "tc-1", deps);
280
+ await runner.run(makeDescriptor(), null, "tc-1");
330
281
  expect(deps.recordSessionApproval).not.toHaveBeenCalled();
331
282
  });
332
283
 
333
284
  describe("denialContext formatting", () => {
334
- function makeDenialContextDescriptor(
335
- denialContext: DenialContext,
336
- overrides: Partial<GateDescriptor> = {},
337
- ): GateDescriptor {
338
- return {
339
- surface: "write",
340
- input: {},
341
- denialContext,
342
- promptDetails: {
343
- source: "tool_call",
344
- agentName: null,
345
- message: "Allow tool 'write'?",
346
- toolCallId: "tc-1",
347
- toolName: "write",
348
- },
349
- logContext: {
350
- source: "tool_call",
351
- toolCallId: "tc-1",
352
- toolName: "write",
353
- },
354
- decision: {
355
- surface: "write",
356
- value: "write",
357
- },
358
- ...overrides,
359
- };
360
- }
361
-
362
285
  it("uses denialContext to format denyReason with extension tag", async () => {
363
- const deps = makeRunnerDeps({
364
- checkPermission: vi
365
- .fn()
366
- .mockReturnValue(
367
- makeCheckResult({ state: "deny", matchedPattern: "*" }),
368
- ),
286
+ const { runner } = makeGateRunner({
287
+ resolveResult: makeCheckResult({ state: "deny", matchedPattern: "*" }),
369
288
  });
370
289
  const ctx: DenialContext = {
371
290
  kind: "tool",
372
291
  check: makeCheckResult({ state: "deny", matchedPattern: "*" }),
373
292
  agentName: "test-agent",
374
293
  };
375
- const result = await runGateCheck(
376
- makeDenialContextDescriptor(ctx),
294
+ const result = await runner.run(
295
+ makeDenialDescriptor(ctx),
377
296
  "test-agent",
378
297
  "tc-1",
379
- deps,
380
298
  );
381
299
  expect(result.action).toBe("block");
382
300
  if (result.action === "block") {
@@ -386,24 +304,15 @@ describe("runGateCheck", () => {
386
304
  });
387
305
 
388
306
  it("uses denialContext to format unavailableReason with extension tag", async () => {
389
- const deps = makeRunnerDeps({
390
- checkPermission: vi
391
- .fn()
392
- .mockReturnValue(
393
- makeCheckResult({ state: "ask", matchedPattern: "*" }),
394
- ),
307
+ const { runner } = makeGateRunner({
308
+ resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
395
309
  canConfirm: vi.fn().mockReturnValue(false),
396
310
  });
397
311
  const ctx: DenialContext = {
398
312
  kind: "tool",
399
313
  check: makeCheckResult({ state: "ask", matchedPattern: "*" }),
400
314
  };
401
- const result = await runGateCheck(
402
- makeDenialContextDescriptor(ctx),
403
- null,
404
- "tc-1",
405
- deps,
406
- );
315
+ const result = await runner.run(makeDenialDescriptor(ctx), null, "tc-1");
407
316
  expect(result.action).toBe("block");
408
317
  if (result.action === "block") {
409
318
  expect(result.reason).toContain(EXTENSION_TAG);
@@ -412,12 +321,8 @@ describe("runGateCheck", () => {
412
321
  });
413
322
 
414
323
  it("uses denialContext to format userDeniedReason with extension tag", async () => {
415
- const deps = makeRunnerDeps({
416
- checkPermission: vi
417
- .fn()
418
- .mockReturnValue(
419
- makeCheckResult({ state: "ask", matchedPattern: "*" }),
420
- ),
324
+ const { runner } = makeGateRunner({
325
+ resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
421
326
  promptPermission: vi.fn().mockResolvedValue({
422
327
  approved: false,
423
328
  state: "denied",
@@ -428,12 +333,7 @@ describe("runGateCheck", () => {
428
333
  kind: "tool",
429
334
  check: makeCheckResult({ state: "ask", matchedPattern: "*" }),
430
335
  };
431
- const result = await runGateCheck(
432
- makeDenialContextDescriptor(ctx),
433
- null,
434
- "tc-1",
435
- deps,
436
- );
336
+ const result = await runner.run(makeDenialDescriptor(ctx), null, "tc-1");
437
337
  expect(result.action).toBe("block");
438
338
  if (result.action === "block") {
439
339
  expect(result.reason).toContain(EXTENSION_TAG);
@@ -442,3 +342,68 @@ describe("runGateCheck", () => {
442
342
  });
443
343
  });
444
344
  });
345
+
346
+ // ── GateRunner.run — null and bypass dispatch ──────────────────────────────
347
+
348
+ describe("GateRunner.run — null and bypass dispatch", () => {
349
+ it("returns allow for a null gate", async () => {
350
+ const { runner, deps } = makeGateRunner();
351
+ const result = await runner.run(null, null, "tc-1");
352
+ expect(result).toEqual({ action: "allow" });
353
+ expect(deps.reporter.writeReviewLog).not.toHaveBeenCalled();
354
+ expect(deps.reporter.emitDecision).not.toHaveBeenCalled();
355
+ });
356
+
357
+ it("returns allow for a bypass with no log or decision", async () => {
358
+ const { runner, deps } = makeGateRunner();
359
+ const bypass: GateBypass = { action: "allow" };
360
+ const result = await runner.run(bypass, null, "tc-1");
361
+ expect(result).toEqual({ action: "allow" });
362
+ expect(deps.reporter.writeReviewLog).not.toHaveBeenCalled();
363
+ expect(deps.reporter.emitDecision).not.toHaveBeenCalled();
364
+ });
365
+
366
+ it("fires writeReviewLog for a bypass with a log entry", async () => {
367
+ const { runner, deps } = makeGateRunner();
368
+ const bypass: GateBypass = {
369
+ action: "allow",
370
+ log: { event: "infra.bypass", details: { path: "/x" } },
371
+ };
372
+ await runner.run(bypass, null, "tc-1");
373
+ expect(deps.reporter.writeReviewLog).toHaveBeenCalledWith("infra.bypass", {
374
+ path: "/x",
375
+ });
376
+ expect(deps.reporter.emitDecision).not.toHaveBeenCalled();
377
+ });
378
+
379
+ it("fires emitDecision for a bypass with a decision", async () => {
380
+ const { runner, deps } = makeGateRunner();
381
+ const decision = {
382
+ surface: "path",
383
+ value: "/x",
384
+ result: "allow" as const,
385
+ resolution: "policy_allow" as const,
386
+ origin: null,
387
+ agentName: null,
388
+ matchedPattern: null,
389
+ };
390
+ const bypass: GateBypass = { action: "allow", decision };
391
+ await runner.run(bypass, null, "tc-1");
392
+ expect(deps.reporter.emitDecision).toHaveBeenCalledWith(decision);
393
+ expect(deps.reporter.writeReviewLog).not.toHaveBeenCalled();
394
+ });
395
+
396
+ it("routes a descriptor to the gate check logic and returns allow", async () => {
397
+ const { runner } = makeGateRunner();
398
+ const result = await runner.run(makeDescriptor(), null, "tc-1");
399
+ expect(result).toEqual({ action: "allow" });
400
+ });
401
+
402
+ it("routes a descriptor to the gate check logic and returns block", async () => {
403
+ const { runner } = makeGateRunner({
404
+ resolveResult: makeCheckResult({ state: "deny", matchedPattern: "*" }),
405
+ });
406
+ const result = await runner.run(makeDescriptor(), null, "tc-1");
407
+ expect(result).toMatchObject({ action: "block" });
408
+ });
409
+ });