@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
@@ -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,11 @@ 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
+ makeResolver,
27
27
  makeTcc,
28
28
  } from "#test/helpers/gate-fixtures";
29
29
 
@@ -31,13 +31,6 @@ afterEach(() => {
31
31
  vi.restoreAllMocks();
32
32
  });
33
33
 
34
- type CheckPermissionFn = (
35
- surface: string,
36
- input: unknown,
37
- agentName?: string,
38
- sessionRules?: Rule[],
39
- ) => PermissionCheckResult;
40
-
41
34
  /**
42
35
  * Mirror the handler's parse-once derivation: parse the bash command into a
43
36
  * shared `BashProgram` and inject it, exactly as `permission-gate-handler.ts`
@@ -45,72 +38,47 @@ type CheckPermissionFn = (
45
38
  */
46
39
  async function describeGate(
47
40
  tcc: ToolCallContext,
48
- checkPermission: CheckPermissionFn,
49
- getSessionRuleset: () => Rule[],
41
+ resolver: PermissionResolver,
50
42
  ): Promise<GateResult> {
51
43
  const command = getNonEmptyString(toRecord(tcc.input).command);
52
44
  const bashProgram =
53
45
  tcc.toolName === "bash" && command
54
46
  ? await BashProgram.parse(command)
55
47
  : null;
56
- return describeBashPathGate(
57
- tcc,
58
- bashProgram,
59
- checkPermission,
60
- getSessionRuleset,
61
- );
48
+ return describeBashPathGate(tcc, bashProgram, resolver);
62
49
  }
63
50
 
64
51
  // ── tests ──────────────────────────────────────────────────────────────────
65
52
 
66
53
  describe("describeBashPathGate", () => {
67
54
  it("returns null for non-bash tools", async () => {
68
- const checkPermission = vi.fn<CheckPermissionFn>();
69
- const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
70
55
  const result = await describeGate(
71
56
  makeTcc({ toolName: "read", input: { path: ".env" } }),
72
- checkPermission,
73
- getSessionRuleset,
57
+ makeResolver(),
74
58
  );
75
59
  expect(result).toBeNull();
76
60
  });
77
61
 
78
62
  it("returns null when no tokens are extracted", async () => {
79
- const checkPermission = vi.fn<CheckPermissionFn>();
80
- const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
81
63
  const result = await describeGate(
82
64
  makeTcc({ input: { command: "echo hello" } }),
83
- checkPermission,
84
- getSessionRuleset,
65
+ makeResolver(),
85
66
  );
86
67
  expect(result).toBeNull();
87
68
  });
88
69
 
89
70
  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
71
  const result = await describeGate(
95
72
  makeTcc({ input: { command: "cat .env" } }),
96
- checkPermission,
97
- getSessionRuleset,
73
+ makeResolver(makeCheckResult({ state: "allow" })),
98
74
  );
99
75
  expect(result).toBeNull();
100
76
  });
101
77
 
102
78
  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
79
  const result = await describeGate(
111
80
  makeTcc({ input: { command: "cat .env" } }),
112
- checkPermission,
113
- getSessionRuleset,
81
+ makeResolver(makeCheckResult({ state: "deny", matchedPattern: "*.env" })),
114
82
  );
115
83
  expect(result).not.toBeNull();
116
84
  expect(isGateDescriptor(result)).toBe(true);
@@ -120,14 +88,9 @@ describe("describeBashPathGate", () => {
120
88
  });
121
89
 
122
90
  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
91
  const result = await describeGate(
128
92
  makeTcc({ input: { command: "cat .env" } }),
129
- checkPermission,
130
- getSessionRuleset,
93
+ makeResolver(makeCheckResult({ state: "ask", matchedPattern: "*" })),
131
94
  );
132
95
  expect(result).not.toBeNull();
133
96
  expect(isGateDescriptor(result)).toBe(true);
@@ -136,16 +99,9 @@ describe("describeBashPathGate", () => {
136
99
  });
137
100
 
138
101
  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
102
  const result = (await describeGate(
146
103
  makeTcc({ input: { command: "cat .env" } }),
147
- checkPermission,
148
- getSessionRuleset,
104
+ makeResolver(makeCheckResult({ state: "deny", matchedPattern: "*.env" })),
149
105
  )) as GateDescriptor;
150
106
  expect(result.denialContext).toMatchObject({
151
107
  kind: "bash_path",
@@ -156,37 +112,17 @@ describe("describeBashPathGate", () => {
156
112
  });
157
113
 
158
114
  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
115
  const result = (await describeGate(
166
116
  makeTcc({ input: { command: "cat .env" } }),
167
- checkPermission,
168
- getSessionRuleset,
117
+ makeResolver(makeCheckResult({ state: "deny", matchedPattern: "*.env" })),
169
118
  )) as GateDescriptor;
170
119
  expect(result.decision.surface).toBe("path");
171
120
  });
172
121
 
173
122
  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
123
  const result = await describeGate(
187
124
  makeTcc({ input: { command: "cat .env" } }),
188
- checkPermission,
189
- getSessionRuleset,
125
+ makeResolver(makeCheckResult({ state: "allow", source: "session" })),
190
126
  );
191
127
  expect(result).not.toBeNull();
192
128
  expect(isGateBypass(result)).toBe(true);
@@ -194,31 +130,22 @@ describe("describeBashPathGate", () => {
194
130
  });
195
131
 
196
132
  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
- );
133
+ const result = await describeGate(makeTcc({ input: {} }), makeResolver());
204
134
  expect(result).toBeNull();
205
135
  });
206
136
 
207
137
  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([]);
138
+ const resolver = makeResolver();
139
+ resolver.resolve.mockImplementation((_surface, input) => {
140
+ const record = input as Record<string, unknown>;
141
+ if (record.path === "src/foo.ts") {
142
+ return makeCheckResult({ state: "allow" });
143
+ }
144
+ return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
145
+ });
218
146
  const result = await describeGate(
219
147
  makeTcc({ input: { command: "cat src/foo.ts .env" } }),
220
- checkPermission,
221
- getSessionRuleset,
148
+ resolver,
222
149
  );
223
150
  expect(result).not.toBeNull();
224
151
  expect(isGateDescriptor(result)).toBe(true);
@@ -226,20 +153,17 @@ describe("describeBashPathGate", () => {
226
153
  });
227
154
 
228
155
  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([]);
156
+ const resolver = makeResolver();
157
+ resolver.resolve.mockImplementation((_surface, input) => {
158
+ const record = input as Record<string, unknown>;
159
+ if (record.path === ".env") {
160
+ return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
161
+ }
162
+ return makeCheckResult({ state: "allow" });
163
+ });
239
164
  const result = await describeGate(
240
165
  makeTcc({ input: { command: "cp .env README.md" } }),
241
- checkPermission,
242
- getSessionRuleset,
166
+ resolver,
243
167
  );
244
168
  expect(result).not.toBeNull();
245
169
  expect(isGateDescriptor(result)).toBe(true);
@@ -249,20 +173,17 @@ describe("describeBashPathGate", () => {
249
173
  });
250
174
 
251
175
  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([]);
176
+ const resolver = makeResolver();
177
+ resolver.resolve.mockImplementation((_surface, input) => {
178
+ const record = input as Record<string, unknown>;
179
+ if (record.path === ".env") {
180
+ return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
181
+ }
182
+ return makeCheckResult({ state: "allow" });
183
+ });
262
184
  const result = await describeGate(
263
185
  makeTcc({ input: { command: "echo test > .env" } }),
264
- checkPermission,
265
- getSessionRuleset,
186
+ resolver,
266
187
  );
267
188
  expect(result).not.toBeNull();
268
189
  expect(isGateDescriptor(result)).toBe(true);
@@ -270,47 +191,41 @@ describe("describeBashPathGate", () => {
270
191
  });
271
192
 
272
193
  it("returns null when all tokens match only the universal default", async () => {
273
- const checkPermission = vi.fn<CheckPermissionFn>().mockReturnValue(
274
- makeCheckResult({
275
- state: "ask",
276
- matchedPattern: undefined,
277
- source: "special",
278
- origin: "builtin",
279
- }),
280
- );
281
- const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
282
194
  const result = await describeGate(
283
195
  makeTcc({ input: { command: "cat .env" } }),
284
- checkPermission,
285
- getSessionRuleset,
196
+ makeResolver(
197
+ makeCheckResult({
198
+ state: "ask",
199
+ matchedPattern: undefined,
200
+ source: "special",
201
+ origin: "builtin",
202
+ }),
203
+ ),
286
204
  );
287
205
  expect(result).toBeNull();
288
206
  });
289
207
 
290
208
  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
209
+ const resolver = makeResolver();
210
+ resolver.resolve.mockImplementation((_surface, input) => {
211
+ const record = input as Record<string, unknown>;
212
+ if (record.path === ".env") {
302
213
  return makeCheckResult({
303
- state: "ask",
304
- matchedPattern: undefined,
305
- source: "special",
306
- origin: "builtin",
214
+ state: "deny",
215
+ matchedPattern: "*.env",
307
216
  });
217
+ }
218
+ // Other tokens match only the universal default
219
+ return makeCheckResult({
220
+ state: "ask",
221
+ matchedPattern: undefined,
222
+ source: "special",
223
+ origin: "builtin",
308
224
  });
309
- const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
225
+ });
310
226
  const result = await describeGate(
311
227
  makeTcc({ input: { command: "cat src/foo.ts .env" } }),
312
- checkPermission,
313
- getSessionRuleset,
228
+ resolver,
314
229
  );
315
230
  expect(result).not.toBeNull();
316
231
  expect(isGateDescriptor(result)).toBe(true);