@gotgenes/pi-permission-system 9.2.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 (73) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +12 -11
  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/io.ts +29 -0
  8. package/src/forwarded-permissions/permission-forwarder.ts +549 -0
  9. package/src/forwarding-manager.ts +3 -7
  10. package/src/gate-handler-session.ts +13 -0
  11. package/src/gate-prompter.ts +14 -0
  12. package/src/handlers/before-agent-start.ts +2 -3
  13. package/src/handlers/gates/bash-command.ts +4 -18
  14. package/src/handlers/gates/bash-external-directory.ts +3 -15
  15. package/src/handlers/gates/bash-path.ts +3 -16
  16. package/src/handlers/gates/descriptor.ts +0 -28
  17. package/src/handlers/gates/path.ts +3 -15
  18. package/src/handlers/gates/runner.ts +142 -105
  19. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  20. package/src/handlers/gates/skill-input.ts +44 -0
  21. package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
  22. package/src/handlers/lifecycle.ts +9 -9
  23. package/src/handlers/permission-gate-handler.ts +34 -238
  24. package/src/index.ts +50 -68
  25. package/src/mcp-targets.ts +56 -46
  26. package/src/permission-event-rpc.ts +7 -0
  27. package/src/permission-events.ts +89 -8
  28. package/src/permission-forwarding.ts +23 -0
  29. package/src/permission-prompter.ts +27 -56
  30. package/src/permission-resolver.ts +17 -0
  31. package/src/permission-session.ts +77 -9
  32. package/src/permission-ui-prompt.ts +127 -0
  33. package/src/permissions-service.ts +53 -0
  34. package/src/service-lifecycle.ts +49 -0
  35. package/src/service.ts +17 -0
  36. package/src/session-approval-recorder.ts +6 -0
  37. package/src/session-lifecycle-session.ts +24 -0
  38. package/src/tool-input-preview.ts +0 -62
  39. package/src/tool-input-prompt-formatters.ts +63 -0
  40. package/src/tool-preview-formatter.ts +6 -4
  41. package/test/composition-root.test.ts +5 -0
  42. package/test/decision-reporter.test.ts +112 -0
  43. package/test/denial-messages.test.ts +62 -0
  44. package/test/forwarding-manager.test.ts +26 -44
  45. package/test/handlers/before-agent-start.test.ts +45 -21
  46. package/test/handlers/external-directory-integration.test.ts +86 -22
  47. package/test/handlers/external-directory-session-dedup.test.ts +102 -55
  48. package/test/handlers/gates/bash-command.test.ts +49 -90
  49. package/test/handlers/gates/bash-external-directory.test.ts +54 -95
  50. package/test/handlers/gates/bash-path.test.ts +63 -148
  51. package/test/handlers/gates/path.test.ts +38 -105
  52. package/test/handlers/gates/runner.test.ts +150 -93
  53. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  54. package/test/handlers/gates/skill-input.test.ts +128 -0
  55. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
  56. package/test/handlers/input.test.ts +1 -2
  57. package/test/handlers/lifecycle.test.ts +49 -33
  58. package/test/handlers/tool-call-events.test.ts +1 -1
  59. package/test/helpers/gate-fixtures.ts +147 -16
  60. package/test/helpers/handler-fixtures.ts +143 -27
  61. package/test/mcp-targets.test.ts +55 -0
  62. package/test/permission-event-rpc.test.ts +39 -0
  63. package/test/permission-events.test.ts +78 -10
  64. package/test/permission-forwarder.test.ts +295 -0
  65. package/test/permission-prompter.test.ts +147 -38
  66. package/test/permission-session.test.ts +160 -27
  67. package/test/permission-ui-prompt.test.ts +146 -0
  68. package/test/permissions-service.test.ts +151 -0
  69. package/test/runtime.test.ts +0 -4
  70. package/test/service-lifecycle.test.ts +162 -0
  71. package/test/tool-input-preview.test.ts +0 -111
  72. package/test/tool-input-prompt-formatters.test.ts +115 -0
  73. package/src/forwarded-permissions/polling.ts +0 -379
@@ -1,18 +1,11 @@
1
- import { describe, expect, it, vi } from "vitest";
1
+ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
4
- import type { Rule } from "#src/rule";
5
4
  import type { PermissionCheckResult } from "#src/types";
6
5
 
6
+ import { makeResolver } from "#test/helpers/gate-fixtures";
7
7
  import { makeCheckResult } from "#test/helpers/handler-fixtures";
8
8
 
9
- type CheckPermissionFn = (
10
- surface: string,
11
- input: unknown,
12
- agentName?: string,
13
- sessionRules?: Rule[],
14
- ) => PermissionCheckResult;
15
-
16
9
  /** Build a bash-surface check result for a single command unit. */
17
10
  function bashResult(
18
11
  state: PermissionCheckResult["state"],
@@ -24,44 +17,40 @@ function bashResult(
24
17
 
25
18
  describe("resolveBashCommandCheck", () => {
26
19
  it("passes a single command straight through", () => {
27
- const checkPermission = vi
28
- .fn<CheckPermissionFn>()
29
- .mockReturnValue(bashResult("allow", "npm install pkg", "npm *"));
20
+ const resolver = makeResolver(
21
+ bashResult("allow", "npm install pkg", "npm *"),
22
+ );
30
23
 
31
24
  const result = resolveBashCommandCheck(
32
25
  "npm install pkg",
33
26
  [{ text: "npm install pkg" }],
34
27
  undefined,
35
- [],
36
- checkPermission,
28
+ resolver,
37
29
  );
38
30
 
39
31
  expect(result.state).toBe("allow");
40
- expect(checkPermission).toHaveBeenCalledTimes(1);
41
- expect(checkPermission).toHaveBeenCalledWith(
32
+ expect(resolver.resolve).toHaveBeenCalledTimes(1);
33
+ expect(resolver.resolve).toHaveBeenCalledWith(
42
34
  "bash",
43
35
  { command: "npm install pkg" },
44
36
  undefined,
45
- [],
46
37
  );
47
38
  });
48
39
 
49
40
  it("denies the chain when any sub-command is denied, reporting that command's pattern", () => {
50
- const checkPermission = vi
51
- .fn<CheckPermissionFn>()
52
- .mockImplementation((_surface, input) => {
53
- const command = (input as { command: string }).command;
54
- return command.startsWith("npm")
55
- ? bashResult("deny", command, "npm *")
56
- : bashResult("allow", command, "cd *");
57
- });
41
+ const resolver = makeResolver();
42
+ resolver.resolve.mockImplementation((_surface, input) => {
43
+ const command = (input as { command: string }).command;
44
+ return command.startsWith("npm")
45
+ ? bashResult("deny", command, "npm *")
46
+ : bashResult("allow", command, "cd *");
47
+ });
58
48
 
59
49
  const result = resolveBashCommandCheck(
60
50
  "cd /p && npm install pkg",
61
51
  [{ text: "cd /p" }, { text: "npm install pkg" }],
62
52
  undefined,
63
- [],
64
- checkPermission,
53
+ resolver,
65
54
  );
66
55
 
67
56
  expect(result.state).toBe("deny");
@@ -70,21 +59,19 @@ describe("resolveBashCommandCheck", () => {
70
59
  });
71
60
 
72
61
  it("asks when a sub-command asks and none denies", () => {
73
- const checkPermission = vi
74
- .fn<CheckPermissionFn>()
75
- .mockImplementation((_surface, input) => {
76
- const command = (input as { command: string }).command;
77
- return command.startsWith("git")
78
- ? bashResult("ask", command, "git *")
79
- : bashResult("allow", command, "cd *");
80
- });
62
+ const resolver = makeResolver();
63
+ resolver.resolve.mockImplementation((_surface, input) => {
64
+ const command = (input as { command: string }).command;
65
+ return command.startsWith("git")
66
+ ? bashResult("ask", command, "git *")
67
+ : bashResult("allow", command, "cd *");
68
+ });
81
69
 
82
70
  const result = resolveBashCommandCheck(
83
71
  "cd /p && git push",
84
72
  [{ text: "cd /p" }, { text: "git push" }],
85
73
  undefined,
86
- [],
87
- checkPermission,
74
+ resolver,
88
75
  );
89
76
 
90
77
  expect(result.state).toBe("ask");
@@ -93,19 +80,17 @@ describe("resolveBashCommandCheck", () => {
93
80
  });
94
81
 
95
82
  it("returns the first allow result when every sub-command is allowed", () => {
96
- const checkPermission = vi
97
- .fn<CheckPermissionFn>()
98
- .mockImplementation((_surface, input) => {
99
- const command = (input as { command: string }).command;
100
- return bashResult("allow", command, `${command} *`);
101
- });
83
+ const resolver = makeResolver();
84
+ resolver.resolve.mockImplementation((_surface, input) => {
85
+ const command = (input as { command: string }).command;
86
+ return bashResult("allow", command, `${command} *`);
87
+ });
102
88
 
103
89
  const result = resolveBashCommandCheck(
104
90
  "a && b",
105
91
  [{ text: "a" }, { text: "b" }],
106
92
  undefined,
107
- [],
108
- checkPermission,
93
+ resolver,
109
94
  );
110
95
 
111
96
  expect(result.state).toBe("allow");
@@ -113,62 +98,40 @@ describe("resolveBashCommandCheck", () => {
113
98
  });
114
99
 
115
100
  it("falls back to the whole command when no top-level commands are found", () => {
116
- const checkPermission = vi
117
- .fn<CheckPermissionFn>()
118
- .mockReturnValue(bashResult("ask", "( rm x )", "*"));
101
+ const resolver = makeResolver(bashResult("ask", "( rm x )", "*"));
119
102
 
120
- const result = resolveBashCommandCheck(
121
- "( rm x )",
122
- [],
123
- undefined,
124
- [],
125
- checkPermission,
126
- );
103
+ const result = resolveBashCommandCheck("( rm x )", [], undefined, resolver);
127
104
 
128
105
  expect(result.state).toBe("ask");
129
106
  expect(result.commandContext).toBeUndefined();
130
- expect(checkPermission).toHaveBeenCalledTimes(1);
131
- expect(checkPermission).toHaveBeenCalledWith(
107
+ expect(resolver.resolve).toHaveBeenCalledTimes(1);
108
+ expect(resolver.resolve).toHaveBeenCalledWith(
132
109
  "bash",
133
110
  { command: "( rm x )" },
134
111
  undefined,
135
- [],
136
112
  );
137
113
  });
138
114
 
139
- it("forwards the agent name and session rules to each sub-command check", () => {
140
- const sessionRules: Rule[] = [
141
- { surface: "bash", pattern: "npm *", action: "allow", origin: "session" },
142
- ];
143
- const checkPermission = vi
144
- .fn<CheckPermissionFn>()
145
- .mockReturnValue(bashResult("allow", "npm i"));
146
-
147
- resolveBashCommandCheck(
148
- "npm i",
149
- [{ text: "npm i" }],
150
- "agent-x",
151
- sessionRules,
152
- checkPermission,
153
- );
115
+ it("forwards the agent name to each sub-command check", () => {
116
+ const resolver = makeResolver(bashResult("allow", "npm i"));
117
+
118
+ resolveBashCommandCheck("npm i", [{ text: "npm i" }], "agent-x", resolver);
154
119
 
155
- expect(checkPermission).toHaveBeenCalledWith(
120
+ expect(resolver.resolve).toHaveBeenCalledWith(
156
121
  "bash",
157
122
  { command: "npm i" },
158
123
  "agent-x",
159
- sessionRules,
160
124
  );
161
125
  });
162
126
 
163
127
  it("tags the winning result with the offending command's execution context", () => {
164
- const checkPermission = vi
165
- .fn<CheckPermissionFn>()
166
- .mockImplementation((_surface, input) => {
167
- const command = (input as { command: string }).command;
168
- return command.startsWith("rm")
169
- ? bashResult("deny", command, "rm *")
170
- : bashResult("allow", command, "echo *");
171
- });
128
+ const resolver = makeResolver();
129
+ resolver.resolve.mockImplementation((_surface, input) => {
130
+ const command = (input as { command: string }).command;
131
+ return command.startsWith("rm")
132
+ ? bashResult("deny", command, "rm *")
133
+ : bashResult("allow", command, "echo *");
134
+ });
172
135
 
173
136
  const result = resolveBashCommandCheck(
174
137
  "echo $(rm -rf foo)",
@@ -177,8 +140,7 @@ describe("resolveBashCommandCheck", () => {
177
140
  { text: "rm -rf foo", context: "command_substitution" },
178
141
  ],
179
142
  undefined,
180
- [],
181
- checkPermission,
143
+ resolver,
182
144
  );
183
145
 
184
146
  expect(result.state).toBe("deny");
@@ -187,16 +149,13 @@ describe("resolveBashCommandCheck", () => {
187
149
  });
188
150
 
189
151
  it("leaves commandContext unset when the winning command is top-level", () => {
190
- const checkPermission = vi
191
- .fn<CheckPermissionFn>()
192
- .mockReturnValue(bashResult("deny", "rm -rf foo", "rm *"));
152
+ const resolver = makeResolver(bashResult("deny", "rm -rf foo", "rm *"));
193
153
 
194
154
  const result = resolveBashCommandCheck(
195
155
  "rm -rf foo",
196
156
  [{ text: "rm -rf foo" }],
197
157
  undefined,
198
- [],
199
- checkPermission,
158
+ resolver,
200
159
  );
201
160
 
202
161
  expect(result.state).toBe("deny");
@@ -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;