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