@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
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, vi } from "vitest";
1
+ import { describe, expect, it } from "vitest";
2
2
  import { getNonEmptyString, toRecord } from "#src/common";
3
3
  import { describeBashExternalDirectoryGate } from "#src/handlers/gates/bash-external-directory";
4
4
  import { BashProgram } from "#src/handlers/gates/bash-program";
@@ -9,8 +9,11 @@ import type {
9
9
  } from "#src/handlers/gates/descriptor";
10
10
  import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
11
11
  import type { ToolCallContext } from "#src/handlers/gates/types";
12
+ import type { PermissionResolver } from "#src/permission-resolver";
12
13
  import type { PermissionCheckResult } from "#src/types";
13
14
 
15
+ import { makeResolver } from "#test/helpers/gate-fixtures";
16
+
14
17
  // ── helpers ────────────────────────────────────────────────────────────────
15
18
 
16
19
  function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
@@ -44,20 +47,14 @@ function makeCheckResult(
44
47
  */
45
48
  async function describeGate(
46
49
  tcc: ToolCallContext,
47
- checkPermission: Parameters<typeof describeBashExternalDirectoryGate>[2],
48
- getSessionRuleset: Parameters<typeof describeBashExternalDirectoryGate>[3],
50
+ resolver: PermissionResolver,
49
51
  ): Promise<GateResult> {
50
52
  const command = getNonEmptyString(toRecord(tcc.input).command);
51
53
  const bashProgram =
52
54
  tcc.toolName === "bash" && command
53
55
  ? await BashProgram.parse(command)
54
56
  : null;
55
- return describeBashExternalDirectoryGate(
56
- tcc,
57
- bashProgram,
58
- checkPermission,
59
- getSessionRuleset,
60
- );
57
+ return describeBashExternalDirectoryGate(tcc, bashProgram, resolver);
61
58
  }
62
59
 
63
60
  // ── tests ──────────────────────────────────────────────────────────────────
@@ -66,8 +63,7 @@ describe("describeBashExternalDirectoryGate", () => {
66
63
  it("returns null when tool is not bash", async () => {
67
64
  const result = await describeGate(
68
65
  makeTcc({ toolName: "read" }),
69
- vi.fn().mockReturnValue(makeCheckResult("ask")),
70
- vi.fn().mockReturnValue([]),
66
+ makeResolver(makeCheckResult("ask")),
71
67
  );
72
68
  expect(result).toBeNull();
73
69
  });
@@ -75,8 +71,7 @@ describe("describeBashExternalDirectoryGate", () => {
75
71
  it("returns null when no CWD", async () => {
76
72
  const result = await describeGate(
77
73
  makeTcc({ cwd: undefined }),
78
- vi.fn().mockReturnValue(makeCheckResult("ask")),
79
- vi.fn().mockReturnValue([]),
74
+ makeResolver(makeCheckResult("ask")),
80
75
  );
81
76
  expect(result).toBeNull();
82
77
  });
@@ -84,21 +79,16 @@ describe("describeBashExternalDirectoryGate", () => {
84
79
  it("returns null when command has no external paths", async () => {
85
80
  const result = await describeGate(
86
81
  makeTcc({ input: { command: "ls -la" } }),
87
- vi.fn().mockReturnValue(makeCheckResult("ask")),
88
- vi.fn().mockReturnValue([]),
82
+ makeResolver(makeCheckResult("ask")),
89
83
  );
90
84
  expect(result).toBeNull();
91
85
  });
92
86
 
93
87
  it("returns GateBypass when all external paths are session-covered", async () => {
94
- const checkPermission = vi
95
- .fn()
96
- .mockReturnValue(makeCheckResult("allow", { source: "session" }));
97
- const result = await describeGate(
98
- makeTcc(),
99
- checkPermission,
100
- vi.fn().mockReturnValue([]),
88
+ const resolver = makeResolver(
89
+ makeCheckResult("allow", { source: "session" }),
101
90
  );
91
+ const result = await describeGate(makeTcc(), resolver);
102
92
  expect(result).not.toBeNull();
103
93
  expect(isGateBypass(result)).toBe(true);
104
94
  const bypass = result as GateBypass;
@@ -110,11 +100,9 @@ describe("describeBashExternalDirectoryGate", () => {
110
100
  });
111
101
 
112
102
  it("returns GateDescriptor with multi-pattern sessionApproval for uncovered paths", async () => {
113
- const checkPermission = vi.fn().mockReturnValue(makeCheckResult("ask"));
114
103
  const result = await describeGate(
115
104
  makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
116
- checkPermission,
117
- vi.fn().mockReturnValue([]),
105
+ makeResolver(makeCheckResult("ask")),
118
106
  );
119
107
  expect(isGateDescriptor(result)).toBe(true);
120
108
  const desc = result as GateDescriptor;
@@ -127,20 +115,13 @@ describe("describeBashExternalDirectoryGate", () => {
127
115
  // Config-level allow (source: "special") should suppress the prompt,
128
116
  // not just session-level allow. This was the bug: source !== "session"
129
117
  // kept config-allowed paths in the uncovered set.
130
- const checkPermission = vi
131
- .fn()
132
- .mockImplementation(
133
- (_surface: string, input: Record<string, unknown>) => {
134
- if (input.path)
135
- return makeCheckResult("allow", { source: "special" });
136
- return makeCheckResult("ask");
137
- },
138
- );
139
- const result = await describeGate(
140
- makeTcc(),
141
- checkPermission,
142
- vi.fn().mockReturnValue([]),
143
- );
118
+ const resolver = makeResolver();
119
+ resolver.resolve.mockImplementation((_surface: string, input: unknown) => {
120
+ if ((input as Record<string, unknown>).path)
121
+ return makeCheckResult("allow", { source: "special" });
122
+ return makeCheckResult("ask");
123
+ });
124
+ const result = await describeGate(makeTcc(), resolver);
144
125
  expect(result).not.toBeNull();
145
126
  expect(isGateBypass(result)).toBe(true);
146
127
  });
@@ -149,20 +130,14 @@ describe("describeBashExternalDirectoryGate", () => {
149
130
  // The path-less extCheck used to always return the "*" catch-all (ask),
150
131
  // silently downgrading a config-level deny to ask. After the fix, the
151
132
  // descriptor's preCheck is derived from the actual path check result.
152
- const checkPermission = vi
153
- .fn()
154
- .mockImplementation(
155
- (_surface: string, input: Record<string, unknown>) => {
156
- if (input.path) return makeCheckResult("deny", { source: "special" });
157
- // Path-less catch-all returns ask — should NOT be used as preCheck.
158
- return makeCheckResult("ask");
159
- },
160
- );
161
- const result = await describeGate(
162
- makeTcc(),
163
- checkPermission,
164
- vi.fn().mockReturnValue([]),
165
- );
133
+ const resolver = makeResolver();
134
+ resolver.resolve.mockImplementation((_surface: string, input: unknown) => {
135
+ if ((input as Record<string, unknown>).path)
136
+ return makeCheckResult("deny", { source: "special" });
137
+ // Path-less catch-all returns ask should NOT be used as preCheck.
138
+ return makeCheckResult("ask");
139
+ });
140
+ const result = await describeGate(makeTcc(), resolver);
166
141
  expect(isGateDescriptor(result)).toBe(true);
167
142
  const desc = result as GateDescriptor;
168
143
  expect(desc.preCheck?.state).toBe("deny");
@@ -171,8 +146,7 @@ describe("describeBashExternalDirectoryGate", () => {
171
146
  it("descriptor surface is 'external_directory'", async () => {
172
147
  const result = await describeGate(
173
148
  makeTcc(),
174
- vi.fn().mockReturnValue(makeCheckResult("ask")),
175
- vi.fn().mockReturnValue([]),
149
+ makeResolver(makeCheckResult("ask")),
176
150
  );
177
151
  const desc = result as GateDescriptor;
178
152
  expect(desc.surface).toBe("external_directory");
@@ -181,8 +155,7 @@ describe("describeBashExternalDirectoryGate", () => {
181
155
  it("descriptor decision surface is 'external_directory'", async () => {
182
156
  const result = await describeGate(
183
157
  makeTcc(),
184
- vi.fn().mockReturnValue(makeCheckResult("ask")),
185
- vi.fn().mockReturnValue([]),
158
+ makeResolver(makeCheckResult("ask")),
186
159
  );
187
160
  const desc = result as GateDescriptor;
188
161
  expect(desc.decision.surface).toBe("external_directory");
@@ -191,8 +164,7 @@ describe("describeBashExternalDirectoryGate", () => {
191
164
  it("denialContext contains the command and external paths", async () => {
192
165
  const result = await describeGate(
193
166
  makeTcc({ input: { command: "cat /outside/file.ts" } }),
194
- vi.fn().mockReturnValue(makeCheckResult("ask")),
195
- vi.fn().mockReturnValue([]),
167
+ makeResolver(makeCheckResult("ask")),
196
168
  );
197
169
  const desc = result as GateDescriptor;
198
170
  expect(desc.denialContext).toMatchObject({
@@ -205,8 +177,7 @@ describe("describeBashExternalDirectoryGate", () => {
205
177
  it("promptDetails includes command and tool_call source", async () => {
206
178
  const result = await describeGate(
207
179
  makeTcc({ agentName: "agent-1", toolCallId: "tc-5" }),
208
- vi.fn().mockReturnValue(makeCheckResult("ask")),
209
- vi.fn().mockReturnValue([]),
180
+ makeResolver(makeCheckResult("ask")),
210
181
  );
211
182
  const desc = result as GateDescriptor;
212
183
  expect(desc.promptDetails).toMatchObject({
@@ -220,19 +191,15 @@ describe("describeBashExternalDirectoryGate", () => {
220
191
 
221
192
  it("config-allowed path is excluded; remaining ask path produces a descriptor", async () => {
222
193
  // One path config-allowed, one config-ask → descriptor with only the ask path.
223
- const checkPermission = vi
224
- .fn()
225
- .mockImplementation(
226
- (_surface: string, input: Record<string, unknown>) => {
227
- if (input.path === "/outside/a.ts")
228
- return makeCheckResult("allow", { source: "special" });
229
- return makeCheckResult("ask");
230
- },
231
- );
194
+ const resolver = makeResolver();
195
+ resolver.resolve.mockImplementation((_surface: string, input: unknown) => {
196
+ if ((input as Record<string, unknown>).path === "/outside/a.ts")
197
+ return makeCheckResult("allow", { source: "special" });
198
+ return makeCheckResult("ask");
199
+ });
232
200
  const result = await describeGate(
233
201
  makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
234
- checkPermission,
235
- vi.fn().mockReturnValue([]),
202
+ resolver,
236
203
  );
237
204
  expect(isGateDescriptor(result)).toBe(true);
238
205
  const desc = result as GateDescriptor;
@@ -244,19 +211,15 @@ describe("describeBashExternalDirectoryGate", () => {
244
211
 
245
212
  it("config-denied path makes worstCheck deny even when another path is ask", async () => {
246
213
  // One path config-denied, one config-ask → descriptor with preCheck.state === "deny".
247
- const checkPermission = vi
248
- .fn()
249
- .mockImplementation(
250
- (_surface: string, input: Record<string, unknown>) => {
251
- if (input.path === "/outside/a.ts")
252
- return makeCheckResult("deny", { source: "special" });
253
- return makeCheckResult("ask");
254
- },
255
- );
214
+ const resolver = makeResolver();
215
+ resolver.resolve.mockImplementation((_surface: string, input: unknown) => {
216
+ if ((input as Record<string, unknown>).path === "/outside/a.ts")
217
+ return makeCheckResult("deny", { source: "special" });
218
+ return makeCheckResult("ask");
219
+ });
256
220
  const result = await describeGate(
257
221
  makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
258
- checkPermission,
259
- vi.fn().mockReturnValue([]),
222
+ resolver,
260
223
  );
261
224
  expect(isGateDescriptor(result)).toBe(true);
262
225
  const desc = result as GateDescriptor;
@@ -268,20 +231,16 @@ describe("describeBashExternalDirectoryGate", () => {
268
231
  });
269
232
 
270
233
  it("only includes uncovered paths when some are session-covered", async () => {
271
- const checkPermission = vi
272
- .fn()
273
- .mockImplementation(
274
- (_surface: string, input: Record<string, unknown>) => {
275
- if (input.path === "/outside/a.ts") {
276
- return makeCheckResult("allow", { source: "session" });
277
- }
278
- return makeCheckResult("ask");
279
- },
280
- );
234
+ const resolver = makeResolver();
235
+ resolver.resolve.mockImplementation((_surface: string, input: unknown) => {
236
+ if ((input as Record<string, unknown>).path === "/outside/a.ts") {
237
+ return makeCheckResult("allow", { source: "session" });
238
+ }
239
+ return makeCheckResult("ask");
240
+ });
281
241
  const result = await describeGate(
282
242
  makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
283
- checkPermission,
284
- vi.fn().mockReturnValue([]),
243
+ resolver,
285
244
  );
286
245
  expect(isGateDescriptor(result)).toBe(true);
287
246
  const desc = result as GateDescriptor;
@@ -19,11 +19,12 @@ import type {
19
19
  } from "#src/handlers/gates/descriptor";
20
20
  import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
21
21
  import type { ToolCallContext } from "#src/handlers/gates/types";
22
- import type { Rule } from "#src/rule";
23
- import type { PermissionCheckResult } from "#src/types";
22
+ import type { PermissionResolver } from "#src/permission-resolver";
24
23
 
25
24
  import {
26
25
  makeGateCheckResult as makeCheckResult,
26
+ makePathDispatchResolver,
27
+ makeResolver,
27
28
  makeTcc,
28
29
  } from "#test/helpers/gate-fixtures";
29
30
 
@@ -31,13 +32,6 @@ afterEach(() => {
31
32
  vi.restoreAllMocks();
32
33
  });
33
34
 
34
- type CheckPermissionFn = (
35
- surface: string,
36
- input: unknown,
37
- agentName?: string,
38
- sessionRules?: Rule[],
39
- ) => PermissionCheckResult;
40
-
41
35
  /**
42
36
  * Mirror the handler's parse-once derivation: parse the bash command into a
43
37
  * shared `BashProgram` and inject it, exactly as `permission-gate-handler.ts`
@@ -45,72 +39,47 @@ type CheckPermissionFn = (
45
39
  */
46
40
  async function describeGate(
47
41
  tcc: ToolCallContext,
48
- checkPermission: CheckPermissionFn,
49
- getSessionRuleset: () => Rule[],
42
+ resolver: PermissionResolver,
50
43
  ): Promise<GateResult> {
51
44
  const command = getNonEmptyString(toRecord(tcc.input).command);
52
45
  const bashProgram =
53
46
  tcc.toolName === "bash" && command
54
47
  ? await BashProgram.parse(command)
55
48
  : null;
56
- return describeBashPathGate(
57
- tcc,
58
- bashProgram,
59
- checkPermission,
60
- getSessionRuleset,
61
- );
49
+ return describeBashPathGate(tcc, bashProgram, resolver);
62
50
  }
63
51
 
64
52
  // ── tests ──────────────────────────────────────────────────────────────────
65
53
 
66
54
  describe("describeBashPathGate", () => {
67
55
  it("returns null for non-bash tools", async () => {
68
- const checkPermission = vi.fn<CheckPermissionFn>();
69
- const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
70
56
  const result = await describeGate(
71
57
  makeTcc({ toolName: "read", input: { path: ".env" } }),
72
- checkPermission,
73
- getSessionRuleset,
58
+ makeResolver(),
74
59
  );
75
60
  expect(result).toBeNull();
76
61
  });
77
62
 
78
63
  it("returns null when no tokens are extracted", async () => {
79
- const checkPermission = vi.fn<CheckPermissionFn>();
80
- const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
81
64
  const result = await describeGate(
82
65
  makeTcc({ input: { command: "echo hello" } }),
83
- checkPermission,
84
- getSessionRuleset,
66
+ makeResolver(),
85
67
  );
86
68
  expect(result).toBeNull();
87
69
  });
88
70
 
89
71
  it("returns null when all tokens evaluate to allow", async () => {
90
- const checkPermission = vi
91
- .fn<CheckPermissionFn>()
92
- .mockReturnValue(makeCheckResult({ state: "allow" }));
93
- const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
94
72
  const result = await describeGate(
95
- makeTcc({ input: { command: "cat .env" } }),
96
- checkPermission,
97
- getSessionRuleset,
73
+ makeTcc(),
74
+ makeResolver(makeCheckResult({ state: "allow" })),
98
75
  );
99
76
  expect(result).toBeNull();
100
77
  });
101
78
 
102
79
  it("returns GateDescriptor when a token evaluates to deny", async () => {
103
- const checkPermission = vi.fn<CheckPermissionFn>().mockReturnValue(
104
- makeCheckResult({
105
- state: "deny",
106
- matchedPattern: "*.env",
107
- }),
108
- );
109
- const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
110
80
  const result = await describeGate(
111
- makeTcc({ input: { command: "cat .env" } }),
112
- checkPermission,
113
- getSessionRuleset,
81
+ makeTcc(),
82
+ makeResolver(makeCheckResult({ state: "deny", matchedPattern: "*.env" })),
114
83
  );
115
84
  expect(result).not.toBeNull();
116
85
  expect(isGateDescriptor(result)).toBe(true);
@@ -120,14 +89,9 @@ describe("describeBashPathGate", () => {
120
89
  });
121
90
 
122
91
  it("returns GateDescriptor when a token evaluates to ask", async () => {
123
- const checkPermission = vi
124
- .fn<CheckPermissionFn>()
125
- .mockReturnValue(makeCheckResult({ state: "ask", matchedPattern: "*" }));
126
- const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
127
92
  const result = await describeGate(
128
- makeTcc({ input: { command: "cat .env" } }),
129
- checkPermission,
130
- getSessionRuleset,
93
+ makeTcc(),
94
+ makeResolver(makeCheckResult({ state: "ask", matchedPattern: "*" })),
131
95
  );
132
96
  expect(result).not.toBeNull();
133
97
  expect(isGateDescriptor(result)).toBe(true);
@@ -136,16 +100,9 @@ describe("describeBashPathGate", () => {
136
100
  });
137
101
 
138
102
  it("descriptor includes triggering token in prompt message", async () => {
139
- const checkPermission = vi
140
- .fn<CheckPermissionFn>()
141
- .mockReturnValue(
142
- makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
143
- );
144
- const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
145
103
  const result = (await describeGate(
146
- makeTcc({ input: { command: "cat .env" } }),
147
- checkPermission,
148
- getSessionRuleset,
104
+ makeTcc(),
105
+ makeResolver(makeCheckResult({ state: "deny", matchedPattern: "*.env" })),
149
106
  )) as GateDescriptor;
150
107
  expect(result.denialContext).toMatchObject({
151
108
  kind: "bash_path",
@@ -156,37 +113,17 @@ describe("describeBashPathGate", () => {
156
113
  });
157
114
 
158
115
  it("descriptor decision uses surface 'path'", async () => {
159
- const checkPermission = vi
160
- .fn<CheckPermissionFn>()
161
- .mockReturnValue(
162
- makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
163
- );
164
- const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
165
116
  const result = (await describeGate(
166
- makeTcc({ input: { command: "cat .env" } }),
167
- checkPermission,
168
- getSessionRuleset,
117
+ makeTcc(),
118
+ makeResolver(makeCheckResult({ state: "deny", matchedPattern: "*.env" })),
169
119
  )) as GateDescriptor;
170
120
  expect(result.decision.surface).toBe("path");
171
121
  });
172
122
 
173
123
  it("returns GateBypass when session rule covers the path", async () => {
174
- const checkPermission = vi
175
- .fn<CheckPermissionFn>()
176
- .mockReturnValue(makeCheckResult({ state: "allow", source: "session" }));
177
- const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([
178
- {
179
- surface: "path",
180
- pattern: "*",
181
- action: "allow",
182
- layer: "session",
183
- origin: "session",
184
- },
185
- ]);
186
124
  const result = await describeGate(
187
- makeTcc({ input: { command: "cat .env" } }),
188
- checkPermission,
189
- getSessionRuleset,
125
+ makeTcc(),
126
+ makeResolver(makeCheckResult({ state: "allow", source: "session" })),
190
127
  );
191
128
  expect(result).not.toBeNull();
192
129
  expect(isGateBypass(result)).toBe(true);
@@ -194,31 +131,18 @@ describe("describeBashPathGate", () => {
194
131
  });
195
132
 
196
133
  it("returns null when command is missing", async () => {
197
- const checkPermission = vi.fn<CheckPermissionFn>();
198
- const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
199
- const result = await describeGate(
200
- makeTcc({ input: {} }),
201
- checkPermission,
202
- getSessionRuleset,
203
- );
134
+ const result = await describeGate(makeTcc({ input: {} }), makeResolver());
204
135
  expect(result).toBeNull();
205
136
  });
206
137
 
207
138
  it("evaluates most restrictive across multiple tokens", async () => {
208
- const checkPermission = vi
209
- .fn<CheckPermissionFn>()
210
- .mockImplementation((_surface, input) => {
211
- const record = input as Record<string, unknown>;
212
- if (record.path === "src/foo.ts") {
213
- return makeCheckResult({ state: "allow" });
214
- }
215
- return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
216
- });
217
- const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
139
+ const resolver = makePathDispatchResolver(
140
+ { "src/foo.ts": makeCheckResult({ state: "allow" }) },
141
+ makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
142
+ );
218
143
  const result = await describeGate(
219
144
  makeTcc({ input: { command: "cat src/foo.ts .env" } }),
220
- checkPermission,
221
- getSessionRuleset,
145
+ resolver,
222
146
  );
223
147
  expect(result).not.toBeNull();
224
148
  expect(isGateDescriptor(result)).toBe(true);
@@ -226,20 +150,13 @@ describe("describeBashPathGate", () => {
226
150
  });
227
151
 
228
152
  it("deny wins in multi-token: cp .env README.md", async () => {
229
- const checkPermission = vi
230
- .fn<CheckPermissionFn>()
231
- .mockImplementation((_surface, input) => {
232
- const record = input as Record<string, unknown>;
233
- if (record.path === ".env") {
234
- return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
235
- }
236
- return makeCheckResult({ state: "allow" });
237
- });
238
- const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
153
+ const resolver = makePathDispatchResolver(
154
+ { ".env": makeCheckResult({ state: "deny", matchedPattern: "*.env" }) },
155
+ makeCheckResult({ state: "allow" }),
156
+ );
239
157
  const result = await describeGate(
240
158
  makeTcc({ input: { command: "cp .env README.md" } }),
241
- checkPermission,
242
- getSessionRuleset,
159
+ resolver,
243
160
  );
244
161
  expect(result).not.toBeNull();
245
162
  expect(isGateDescriptor(result)).toBe(true);
@@ -249,20 +166,13 @@ describe("describeBashPathGate", () => {
249
166
  });
250
167
 
251
168
  it("extracts redirect target: echo test > .env triggers deny", async () => {
252
- const checkPermission = vi
253
- .fn<CheckPermissionFn>()
254
- .mockImplementation((_surface, input) => {
255
- const record = input as Record<string, unknown>;
256
- if (record.path === ".env") {
257
- return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
258
- }
259
- return makeCheckResult({ state: "allow" });
260
- });
261
- const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
169
+ const resolver = makePathDispatchResolver(
170
+ { ".env": makeCheckResult({ state: "deny", matchedPattern: "*.env" }) },
171
+ makeCheckResult({ state: "allow" }),
172
+ );
262
173
  const result = await describeGate(
263
174
  makeTcc({ input: { command: "echo test > .env" } }),
264
- checkPermission,
265
- getSessionRuleset,
175
+ resolver,
266
176
  );
267
177
  expect(result).not.toBeNull();
268
178
  expect(isGateDescriptor(result)).toBe(true);
@@ -270,7 +180,24 @@ describe("describeBashPathGate", () => {
270
180
  });
271
181
 
272
182
  it("returns null when all tokens match only the universal default", async () => {
273
- const checkPermission = vi.fn<CheckPermissionFn>().mockReturnValue(
183
+ const result = await describeGate(
184
+ makeTcc(),
185
+ makeResolver(
186
+ makeCheckResult({
187
+ state: "ask",
188
+ matchedPattern: undefined,
189
+ source: "special",
190
+ origin: "builtin",
191
+ }),
192
+ ),
193
+ );
194
+ expect(result).toBeNull();
195
+ });
196
+
197
+ it("ignores tokens matching universal default but fires for explicit rule matches", async () => {
198
+ const resolver = makePathDispatchResolver(
199
+ { ".env": makeCheckResult({ state: "deny", matchedPattern: "*.env" }) },
200
+ // Other tokens match only the universal default (no matchedPattern)
274
201
  makeCheckResult({
275
202
  state: "ask",
276
203
  matchedPattern: undefined,
@@ -278,39 +205,9 @@ describe("describeBashPathGate", () => {
278
205
  origin: "builtin",
279
206
  }),
280
207
  );
281
- const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
282
- const result = await describeGate(
283
- makeTcc({ input: { command: "cat .env" } }),
284
- checkPermission,
285
- getSessionRuleset,
286
- );
287
- expect(result).toBeNull();
288
- });
289
-
290
- it("ignores tokens matching universal default but fires for explicit rule matches", async () => {
291
- const checkPermission = vi
292
- .fn<CheckPermissionFn>()
293
- .mockImplementation((_surface, input) => {
294
- const record = input as Record<string, unknown>;
295
- if (record.path === ".env") {
296
- return makeCheckResult({
297
- state: "deny",
298
- matchedPattern: "*.env",
299
- });
300
- }
301
- // Other tokens match only the universal default
302
- return makeCheckResult({
303
- state: "ask",
304
- matchedPattern: undefined,
305
- source: "special",
306
- origin: "builtin",
307
- });
308
- });
309
- const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
310
208
  const result = await describeGate(
311
209
  makeTcc({ input: { command: "cat src/foo.ts .env" } }),
312
- checkPermission,
313
- getSessionRuleset,
210
+ resolver,
314
211
  );
315
212
  expect(result).not.toBeNull();
316
213
  expect(isGateDescriptor(result)).toBe(true);