@gotgenes/pi-permission-system 10.4.0 → 10.5.1

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 (33) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +1 -1
  3. package/src/handlers/before-agent-start.ts +11 -6
  4. package/src/handlers/gates/bash-command.ts +2 -2
  5. package/src/handlers/gates/bash-external-directory.ts +2 -2
  6. package/src/handlers/gates/bash-path.ts +2 -2
  7. package/src/handlers/gates/path.ts +2 -2
  8. package/src/handlers/gates/runner.ts +2 -2
  9. package/src/handlers/gates/tool-call-gate-pipeline.ts +10 -9
  10. package/src/handlers/lifecycle.ts +7 -4
  11. package/src/handlers/permission-gate-handler.ts +3 -3
  12. package/src/index.ts +13 -4
  13. package/src/permission-resolver.ts +66 -2
  14. package/src/permission-session.ts +8 -72
  15. package/src/session-rules.ts +3 -2
  16. package/src/skill-prompt-sanitizer.ts +1 -1
  17. package/test/handlers/before-agent-start.test.ts +56 -86
  18. package/test/handlers/external-directory-session-dedup.test.ts +80 -160
  19. package/test/handlers/gates/bash-external-directory.test.ts +2 -2
  20. package/test/handlers/gates/bash-path.test.ts +2 -2
  21. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +30 -21
  22. package/test/handlers/input.test.ts +5 -4
  23. package/test/handlers/lifecycle.test.ts +79 -85
  24. package/test/handlers/tool-call.test.ts +3 -2
  25. package/test/helpers/gate-fixtures.ts +5 -9
  26. package/test/helpers/handler-fixtures.ts +100 -107
  27. package/test/helpers/session-fixtures.ts +192 -0
  28. package/test/permission-resolver.test.ts +196 -0
  29. package/test/permission-session.test.ts +14 -266
  30. package/test/session-rules.test.ts +13 -5
  31. package/src/agent-prep-session.ts +0 -28
  32. package/src/gate-handler-session.ts +0 -13
  33. package/src/session-lifecycle-session.ts +0 -24
@@ -1,6 +1,5 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
 
3
- import type { AgentPrepSession } from "#src/agent-prep-session";
4
3
  import {
5
4
  AgentPrepHandler,
6
5
  shouldExposeTool,
@@ -8,6 +7,10 @@ import {
8
7
  import type { ToolRegistry } from "#src/tool-registry";
9
8
 
10
9
  import { makeCheckResult, makeCtx } from "#test/helpers/handler-fixtures";
10
+ import {
11
+ makeRealResolver,
12
+ makeRealSession,
13
+ } from "#test/helpers/session-fixtures";
11
14
 
12
15
  // ── SDK stubs ──────────────────────────────────────────────────────────────
13
16
  vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
@@ -25,51 +28,6 @@ function makeEvent(systemPrompt = "You are an assistant.") {
25
28
  return { systemPrompt };
26
29
  }
27
30
 
28
- function makeSession(
29
- overrides: Partial<AgentPrepSession> = {},
30
- ): AgentPrepSession {
31
- return {
32
- activate: overrides.activate ?? vi.fn<AgentPrepSession["activate"]>(),
33
- refreshConfig:
34
- overrides.refreshConfig ?? vi.fn<AgentPrepSession["refreshConfig"]>(),
35
- resolveAgentName:
36
- overrides.resolveAgentName ??
37
- vi.fn<AgentPrepSession["resolveAgentName"]>().mockReturnValue(null),
38
- checkPermission:
39
- overrides.checkPermission ??
40
- vi
41
- .fn<AgentPrepSession["checkPermission"]>()
42
- .mockReturnValue(makeCheckResult()),
43
- getToolPermission:
44
- overrides.getToolPermission ??
45
- vi.fn<AgentPrepSession["getToolPermission"]>().mockReturnValue("allow"),
46
- shouldUpdateActiveTools:
47
- overrides.shouldUpdateActiveTools ??
48
- vi
49
- .fn<AgentPrepSession["shouldUpdateActiveTools"]>()
50
- .mockReturnValue(true),
51
- commitActiveToolsCacheKey:
52
- overrides.commitActiveToolsCacheKey ??
53
- vi.fn<AgentPrepSession["commitActiveToolsCacheKey"]>(),
54
- getPolicyCacheStamp:
55
- overrides.getPolicyCacheStamp ??
56
- vi
57
- .fn<AgentPrepSession["getPolicyCacheStamp"]>()
58
- .mockReturnValue("stamp-1"),
59
- shouldUpdatePromptState:
60
- overrides.shouldUpdatePromptState ??
61
- vi
62
- .fn<AgentPrepSession["shouldUpdatePromptState"]>()
63
- .mockReturnValue(true),
64
- commitPromptStateCacheKey:
65
- overrides.commitPromptStateCacheKey ??
66
- vi.fn<AgentPrepSession["commitPromptStateCacheKey"]>(),
67
- setActiveSkillEntries:
68
- overrides.setActiveSkillEntries ??
69
- vi.fn<AgentPrepSession["setActiveSkillEntries"]>(),
70
- };
71
- }
72
-
73
31
  function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
74
32
  return {
75
33
  getAll: vi.fn().mockReturnValue([]),
@@ -78,18 +36,33 @@ function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
78
36
  };
79
37
  }
80
38
 
81
- function makeHandler(overrides?: {
82
- session?: Partial<AgentPrepSession>;
39
+ function makeSetup(opts?: {
40
+ toolPermission?: "allow" | "deny" | "ask";
83
41
  toolRegistry?: Partial<ToolRegistry>;
84
- }): {
85
- handler: AgentPrepHandler;
86
- session: AgentPrepSession;
87
- toolRegistry: ToolRegistry;
88
- } {
89
- const session = makeSession(overrides?.session);
90
- const toolRegistry = makeToolRegistry(overrides?.toolRegistry);
91
- const handler = new AgentPrepHandler(session, toolRegistry);
92
- return { handler, session, toolRegistry };
42
+ }) {
43
+ const { session, permissionManager, sessionRules, configStore, forwarding } =
44
+ makeRealSession();
45
+ const { resolver } = makeRealResolver(permissionManager, sessionRules);
46
+ if (opts?.toolPermission !== undefined) {
47
+ vi.mocked(permissionManager.getToolPermission).mockReturnValue(
48
+ opts.toolPermission,
49
+ );
50
+ }
51
+ // Default checkPermission returns allow (for skill-prompt sanitizer)
52
+ vi.mocked(permissionManager.checkPermission).mockReturnValue(
53
+ makeCheckResult(),
54
+ );
55
+ const toolRegistry = makeToolRegistry(opts?.toolRegistry);
56
+ const handler = new AgentPrepHandler(session, resolver, toolRegistry);
57
+ return {
58
+ handler,
59
+ session,
60
+ resolver,
61
+ permissionManager,
62
+ configStore,
63
+ forwarding,
64
+ toolRegistry,
65
+ };
93
66
  }
94
67
 
95
68
  // ── shouldExposeTool (pure helper) ─────────────────────────────────────────
@@ -128,31 +101,30 @@ describe("shouldExposeTool", () => {
128
101
  describe("AgentPrepHandler.handle", () => {
129
102
  it("activates the session with ctx", async () => {
130
103
  const ctx = makeCtx();
131
- const { handler, session } = makeHandler();
104
+ const { handler, forwarding } = makeSetup();
132
105
  await handler.handle(makeEvent(), ctx);
133
- expect(session.activate).toHaveBeenCalledWith(ctx);
106
+ // Real session.activate calls forwarding.start
107
+ expect(forwarding.start).toHaveBeenCalledWith(ctx);
134
108
  });
135
109
 
136
110
  it("refreshes config with ctx", async () => {
137
111
  const ctx = makeCtx();
138
- const { handler, session } = makeHandler();
112
+ const { handler, configStore } = makeSetup();
139
113
  await handler.handle(makeEvent(), ctx);
140
- expect(session.refreshConfig).toHaveBeenCalledWith(ctx);
114
+ expect(configStore.refresh).toHaveBeenCalledWith(ctx);
141
115
  });
142
116
 
143
117
  it("resolves agent name using systemPrompt", async () => {
144
118
  const ctx = makeCtx();
145
- const { handler, session } = makeHandler();
119
+ const { handler, session } = makeSetup();
120
+ const spy = vi.spyOn(session, "resolveAgentName");
146
121
  await handler.handle(makeEvent("<active_agent name='x'>"), ctx);
147
- expect(session.resolveAgentName).toHaveBeenCalledWith(
148
- ctx,
149
- "<active_agent name='x'>",
150
- );
122
+ expect(spy).toHaveBeenCalledWith(ctx, "<active_agent name='x'>");
151
123
  });
152
124
 
153
125
  it("filters out denied tools from allowed list", async () => {
154
- const { handler, toolRegistry } = makeHandler({
155
- session: { getToolPermission: vi.fn().mockReturnValue("deny") },
126
+ const { handler, toolRegistry } = makeSetup({
127
+ toolPermission: "deny",
156
128
  toolRegistry: {
157
129
  getAll: vi.fn().mockReturnValue([{ name: "write" }, { name: "read" }]),
158
130
  },
@@ -162,7 +134,7 @@ describe("AgentPrepHandler.handle", () => {
162
134
  });
163
135
 
164
136
  it("includes allowed and ask tools in the active list", async () => {
165
- const { handler, toolRegistry } = makeHandler({
137
+ const { handler, toolRegistry } = makeSetup({
166
138
  toolRegistry: {
167
139
  getAll: vi.fn().mockReturnValue([{ name: "read" }, { name: "write" }]),
168
140
  },
@@ -172,60 +144,58 @@ describe("AgentPrepHandler.handle", () => {
172
144
  });
173
145
 
174
146
  it("commits active-tools cache key after applying", async () => {
175
- const { handler, session } = makeHandler({
147
+ const { handler, session } = makeSetup({
176
148
  toolRegistry: {
177
149
  getAll: vi.fn().mockReturnValue([{ name: "read" }]),
178
150
  },
179
151
  });
152
+ const spy = vi.spyOn(session, "commitActiveToolsCacheKey");
180
153
  await handler.handle(makeEvent(), makeCtx());
181
- expect(session.commitActiveToolsCacheKey).toHaveBeenCalled();
154
+ expect(spy).toHaveBeenCalled();
182
155
  });
183
156
 
184
157
  it("skips setActive when cache key is unchanged", async () => {
185
- const { handler, session, toolRegistry } = makeHandler({
186
- session: { shouldUpdateActiveTools: vi.fn().mockReturnValue(false) },
158
+ const { handler, session, toolRegistry } = makeSetup({
187
159
  toolRegistry: {
188
160
  getAll: vi.fn().mockReturnValue([{ name: "read" }]),
189
161
  },
190
162
  });
163
+ vi.spyOn(session, "shouldUpdateActiveTools").mockReturnValue(false);
191
164
  await handler.handle(makeEvent(), makeCtx());
192
165
  expect(toolRegistry.setActive).not.toHaveBeenCalled();
193
- expect(session.commitActiveToolsCacheKey).not.toHaveBeenCalled();
194
166
  });
195
167
 
196
168
  it("returns empty object when prompt cache is unchanged", async () => {
197
- const { handler, session } = makeHandler({
198
- session: { shouldUpdatePromptState: vi.fn().mockReturnValue(false) },
199
- });
169
+ const { handler, session } = makeSetup();
170
+ vi.spyOn(session, "shouldUpdatePromptState").mockReturnValue(false);
200
171
  const result = await handler.handle(makeEvent(), makeCtx());
201
172
  expect(result).toEqual({});
202
- expect(session.commitPromptStateCacheKey).not.toHaveBeenCalled();
203
173
  });
204
174
 
205
175
  it("commits prompt-state cache key and processes prompt when cache is new", async () => {
206
- const { handler, session } = makeHandler();
176
+ const { handler, session } = makeSetup();
177
+ const spy = vi.spyOn(session, "commitPromptStateCacheKey");
207
178
  await handler.handle(makeEvent(), makeCtx());
208
- expect(session.commitPromptStateCacheKey).toHaveBeenCalled();
179
+ expect(spy).toHaveBeenCalled();
209
180
  });
210
181
 
211
182
  it("stores resolved skill entries on the session", async () => {
212
- const { handler, session } = makeHandler();
183
+ const { handler, session } = makeSetup();
184
+ const spy = vi.spyOn(session, "setActiveSkillEntries");
213
185
  await handler.handle(makeEvent(), makeCtx());
214
- expect(session.setActiveSkillEntries).toHaveBeenCalledWith(
215
- expect.any(Array),
216
- );
186
+ expect(spy).toHaveBeenCalledWith(expect.any(Array));
217
187
  });
218
188
 
219
189
  it("returns modified systemPrompt when prompt changes", async () => {
220
190
  const systemPrompt = `You are an assistant.\n\nAvailable tools:\n- read\n- write\n`;
221
- const { handler } = makeHandler();
191
+ const { handler } = makeSetup();
222
192
  const result = await handler.handle(makeEvent(systemPrompt), makeCtx());
223
193
  expect(result).toHaveProperty("systemPrompt");
224
194
  });
225
195
 
226
196
  it("returns empty object when systemPrompt is unchanged", async () => {
227
197
  const prompt = "No tools section here.";
228
- const { handler } = makeHandler();
198
+ const { handler } = makeSetup();
229
199
  const result = await handler.handle(makeEvent(prompt), makeCtx());
230
200
  expect(result).toEqual({});
231
201
  });
@@ -3,33 +3,30 @@
3
3
  * external path only prompt once — the session-approval recorded by the
4
4
  * first call covers the second.
5
5
  *
6
- * These tests use stateful mocks: `recordSessionApproval` records rules,
7
- * and `checkPermission` consults them via `getSessionRuleset`, mirroring
8
- * the real interaction between PermissionSession, SessionRules, and
9
- * PermissionManager.
6
+ * Uses real PermissionSession + PermissionResolver + SessionRules so the
7
+ * stateful approval-tracking path is exercised end-to-end.
10
8
  */
11
9
 
12
10
  import { describe, expect, it, vi } from "vitest";
13
11
 
14
12
  import { GateDecisionReporter } from "#src/decision-reporter";
15
- import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
16
13
  import type { GatePrompter } from "#src/gate-prompter";
17
14
  import { GateRunner } from "#src/handlers/gates/runner";
18
15
  import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
19
16
  import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
20
17
  import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
21
- import type { Rule } from "#src/rule";
22
- import type { SessionApproval } from "#src/session-approval";
23
- import { resolveToolPreviewLimits } from "#src/tool-preview-formatter";
24
- import type { ToolRegistry } from "#src/tool-registry";
25
18
  import type { PermissionCheckResult } from "#src/types";
26
19
  import { wildcardMatch } from "#src/wildcard-matcher";
27
20
 
28
21
  import {
29
- type MockGateHandlerSession,
30
22
  makeCtx,
31
23
  makeEvents,
24
+ makeToolRegistry,
32
25
  } from "#test/helpers/handler-fixtures";
26
+ import {
27
+ makeRealResolver,
28
+ makeRealSession,
29
+ } from "#test/helpers/session-fixtures";
33
30
 
34
31
  // ── SDK stub ───────────────────────────────────────────────────────────────
35
32
  vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
@@ -41,177 +38,105 @@ vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
41
38
  // ── helpers ────────────────────────────────────────────────────────────────
42
39
 
43
40
  /**
44
- * Build a PermissionSession mock with stateful session-rule tracking.
41
+ * Build a fully wired PermissionGateHandler for external-directory dedup
42
+ * tests.
45
43
  *
46
- * `checkPermission` returns "ask" for `external_directory` unless a
47
- * matching session rule exists (via `recordSessionApproval`), in which case
48
- * it returns "allow" with `source: "session"`. All other surfaces return
49
- * "allow" by default.
44
+ * `permissionManager.checkPermission` is configured so that:
45
+ * - `external_directory` surface returns "ask" on first call
46
+ * - On subsequent calls it checks the shared `sessionRules` store; if a
47
+ * matching rule was recorded by the runner, it returns "allow" with
48
+ * `source: "session"`.
49
+ * - All other surfaces return "allow".
50
50
  */
51
- function makeStatefulSession(
52
- overrides: Partial<MockGateHandlerSession> = {},
53
- ): MockGateHandlerSession {
54
- const sessionRules: Rule[] = [];
55
-
56
- const checkPermission = vi
57
- .fn<MockGateHandlerSession["checkPermission"]>()
58
- .mockImplementation(
59
- (
60
- surface: string,
61
- input: unknown,
62
- _agentName?: string,
63
- rules?: Rule[],
64
- ): PermissionCheckResult => {
65
- // Merge stored session rules with any passed-in rules
66
- const allRules = [...sessionRules, ...(rules ?? [])];
67
-
68
- if (surface === "external_directory") {
69
- const record = (input ?? {}) as Record<string, unknown>;
70
- const pathValue =
71
- typeof record.path === "string" ? record.path : null;
72
-
73
- if (pathValue && allRules.length > 0) {
74
- const match = allRules.findLast(
75
- (r) =>
76
- r.surface === "external_directory" &&
77
- wildcardMatch(r.pattern, pathValue),
78
- );
79
- if (match) {
80
- return {
81
- state: "allow",
82
- toolName: surface,
83
- source: "session",
84
- origin: "session",
85
- matchedPattern: match.pattern,
86
- };
87
- }
51
+ function makeDeduplicatingHandler(prompter?: GatePrompter): {
52
+ handler: PermissionGateHandler;
53
+ prompter: GatePrompter;
54
+ } {
55
+ const { session, permissionManager, sessionRules, logger } =
56
+ makeRealSession();
57
+ const { resolver } = makeRealResolver(permissionManager, sessionRules);
58
+
59
+ // Configure checkPermission to simulate config-level "ask" for external_directory
60
+ // but return "allow/session" when a session rule has been recorded.
61
+ vi.mocked(permissionManager.checkPermission).mockImplementation(
62
+ (surface, input, _agentName, rules): PermissionCheckResult => {
63
+ if (surface === "external_directory") {
64
+ const record = (input ?? {}) as Record<string, unknown>;
65
+ const pathValue = typeof record.path === "string" ? record.path : null;
66
+
67
+ if (pathValue && rules && rules.length > 0) {
68
+ const match = rules.findLast(
69
+ (r) =>
70
+ r.surface === "external_directory" &&
71
+ wildcardMatch(r.pattern, pathValue),
72
+ );
73
+ if (match) {
74
+ return {
75
+ state: "allow",
76
+ toolName: surface,
77
+ source: "session",
78
+ origin: "session",
79
+ matchedPattern: match.pattern,
80
+ };
88
81
  }
89
-
90
- // No session match → config-level "ask"
91
- return {
92
- state: "ask",
93
- toolName: surface,
94
- source: "special",
95
- origin: "global",
96
- };
97
82
  }
98
83
 
99
- // All other surfaces: allow
100
84
  return {
101
- state: "allow",
85
+ state: "ask",
102
86
  toolName: surface,
103
- source: "tool",
104
- origin: "builtin",
87
+ source: "special",
88
+ origin: "global",
105
89
  };
106
- },
107
- );
108
-
109
- const recordSessionApproval = vi
110
- .fn<MockGateHandlerSession["recordSessionApproval"]>()
111
- .mockImplementation((approval: SessionApproval) => {
112
- for (const pattern of approval.patterns) {
113
- sessionRules.push({
114
- surface: approval.surface,
115
- pattern,
116
- action: "allow",
117
- layer: "session",
118
- origin: "session",
119
- });
120
90
  }
121
- });
122
-
123
- const getSessionRuleset = vi
124
- .fn<MockGateHandlerSession["getSessionRuleset"]>()
125
- .mockImplementation(() => [...sessionRules]);
126
91
 
127
- const session: MockGateHandlerSession = {
128
- logger: overrides.logger ?? {
129
- debug: vi.fn(),
130
- review: vi.fn(),
131
- warn: vi.fn(),
92
+ return {
93
+ state: "allow",
94
+ toolName: surface,
95
+ source: "tool",
96
+ origin: "builtin",
97
+ };
132
98
  },
133
- activate: overrides.activate ?? vi.fn<MockGateHandlerSession["activate"]>(),
134
- resolveAgentName:
135
- overrides.resolveAgentName ??
136
- vi.fn<MockGateHandlerSession["resolveAgentName"]>().mockReturnValue(null),
137
- checkPermission: overrides.checkPermission ?? checkPermission,
138
- getSessionRuleset: overrides.getSessionRuleset ?? getSessionRuleset,
139
- recordSessionApproval:
140
- overrides.recordSessionApproval ?? recordSessionApproval,
141
- getActiveSkillEntries:
142
- overrides.getActiveSkillEntries ??
143
- vi
144
- .fn<MockGateHandlerSession["getActiveSkillEntries"]>()
145
- .mockReturnValue([]),
146
- getInfrastructureReadDirs:
147
- overrides.getInfrastructureReadDirs ??
148
- vi
149
- .fn<MockGateHandlerSession["getInfrastructureReadDirs"]>()
150
- .mockReturnValue([]),
151
- getToolPreviewLimits:
152
- overrides.getToolPreviewLimits ??
153
- vi
154
- .fn<MockGateHandlerSession["getToolPreviewLimits"]>()
155
- .mockReturnValue(resolveToolPreviewLimits(DEFAULT_EXTENSION_CONFIG)),
156
- // Resolve delegation — closure reads `session` at call time so overrides win.
157
- resolve:
158
- overrides.resolve ??
159
- vi.fn<MockGateHandlerSession["resolve"]>((surface, input, agentName) =>
160
- session.checkPermission(
161
- surface,
162
- input,
163
- agentName,
164
- session.getSessionRuleset(),
165
- ),
166
- ),
167
- };
168
- return session;
169
- }
99
+ );
170
100
 
171
- function makeHandlerForSession(
172
- session: MockGateHandlerSession,
173
- prompter?: GatePrompter,
174
- ): { handler: PermissionGateHandler; prompter: GatePrompter } {
175
101
  const events = makeEvents();
176
- const reporter = new GateDecisionReporter(session.logger, events);
102
+ const reporter = new GateDecisionReporter(logger, events);
177
103
  const resolvedPrompter: GatePrompter = prompter ?? {
178
104
  canConfirm: vi.fn().mockReturnValue(true),
179
105
  prompt: vi
180
106
  .fn<GatePrompter["prompt"]>()
181
107
  .mockResolvedValue({ approved: true, state: "approved_for_session" }),
182
108
  };
183
- const runner = new GateRunner(session, session, resolvedPrompter, reporter);
109
+ const runner = new GateRunner(
110
+ resolver,
111
+ sessionRules,
112
+ resolvedPrompter,
113
+ reporter,
114
+ );
184
115
  const handler = new PermissionGateHandler(
185
116
  session,
186
- makeToolRegistry(),
187
- new ToolCallGatePipeline(session),
188
- new SkillInputGatePipeline(session),
117
+ makeToolRegistry({
118
+ getAll: vi
119
+ .fn()
120
+ .mockReturnValue([
121
+ { name: "read" },
122
+ { name: "write" },
123
+ { name: "edit" },
124
+ { name: "bash" },
125
+ ]),
126
+ }),
127
+ new ToolCallGatePipeline(resolver, session),
128
+ new SkillInputGatePipeline(resolver),
189
129
  runner,
190
130
  );
191
131
  return { handler, prompter: resolvedPrompter };
192
132
  }
193
133
 
194
- function makeToolRegistry(): ToolRegistry {
195
- return {
196
- getAll: vi
197
- .fn()
198
- .mockReturnValue([
199
- { name: "read" },
200
- { name: "write" },
201
- { name: "edit" },
202
- { name: "bash" },
203
- ]),
204
- setActive: vi.fn(),
205
- };
206
- }
207
-
208
134
  // ── tests ──────────────────────────────────────────────────────────────────
209
135
 
210
136
  describe("external-directory session dedup", () => {
211
137
  describe("path-bearing tools (read, write, edit)", () => {
212
138
  it("does not re-prompt for the same external path after session approval", async () => {
213
- const session = makeStatefulSession();
214
- const { handler, prompter } = makeHandlerForSession(session);
139
+ const { handler, prompter } = makeDeduplicatingHandler();
215
140
  const ctx = makeCtx();
216
141
  const externalPath = "/outside/project/data.txt";
217
142
 
@@ -239,8 +164,7 @@ describe("external-directory session dedup", () => {
239
164
  });
240
165
 
241
166
  it("does not re-prompt for a different file in the same external directory", async () => {
242
- const session = makeStatefulSession();
243
- const { handler, prompter } = makeHandlerForSession(session);
167
+ const { handler, prompter } = makeDeduplicatingHandler();
244
168
  const ctx = makeCtx();
245
169
 
246
170
  // First call — prompt for /outside/project/a.txt
@@ -265,8 +189,7 @@ describe("external-directory session dedup", () => {
265
189
  });
266
190
 
267
191
  it("does prompt for a file in a different external directory", async () => {
268
- const session = makeStatefulSession();
269
- const { handler, prompter } = makeHandlerForSession(session);
192
+ const { handler, prompter } = makeDeduplicatingHandler();
270
193
  const ctx = makeCtx();
271
194
 
272
195
  // First call — /outside/alpha/file.txt
@@ -291,14 +214,13 @@ describe("external-directory session dedup", () => {
291
214
  });
292
215
 
293
216
  it("re-prompts when user approved once (not for session)", async () => {
294
- const session = makeStatefulSession();
295
217
  const approveOnce: GatePrompter = {
296
218
  canConfirm: vi.fn().mockReturnValue(true),
297
219
  prompt: vi
298
220
  .fn<GatePrompter["prompt"]>()
299
221
  .mockResolvedValue({ approved: true, state: "approved" }),
300
222
  };
301
- const { handler, prompter } = makeHandlerForSession(session, approveOnce);
223
+ const { handler, prompter } = makeDeduplicatingHandler(approveOnce);
302
224
  const ctx = makeCtx();
303
225
  const externalPath = "/outside/project/data.txt";
304
226
 
@@ -326,8 +248,7 @@ describe("external-directory session dedup", () => {
326
248
 
327
249
  describe("bash commands with external paths", () => {
328
250
  it("does not re-prompt for a bash command referencing the same external path after session approval", async () => {
329
- const session = makeStatefulSession();
330
- const { handler, prompter } = makeHandlerForSession(session);
251
+ const { handler, prompter } = makeDeduplicatingHandler();
331
252
  const ctx = makeCtx();
332
253
 
333
254
  // First call — bash referencing /tmp/out.txt
@@ -354,8 +275,7 @@ describe("external-directory session dedup", () => {
354
275
  });
355
276
 
356
277
  it("does not re-prompt for read after bash already approved the same directory", async () => {
357
- const session = makeStatefulSession();
358
- const { handler, prompter } = makeHandlerForSession(session);
278
+ const { handler, prompter } = makeDeduplicatingHandler();
359
279
  const ctx = makeCtx();
360
280
 
361
281
  // First call — bash writes to /tmp/out.txt
@@ -9,7 +9,7 @@ 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
+ import type { ScopedPermissionResolver } from "#src/permission-resolver";
13
13
  import type { PermissionCheckResult } from "#src/types";
14
14
 
15
15
  import { makeResolver } from "#test/helpers/gate-fixtures";
@@ -47,7 +47,7 @@ function makeCheckResult(
47
47
  */
48
48
  async function describeGate(
49
49
  tcc: ToolCallContext,
50
- resolver: PermissionResolver,
50
+ resolver: ScopedPermissionResolver,
51
51
  ): Promise<GateResult> {
52
52
  const command = getNonEmptyString(toRecord(tcc.input).command);
53
53
  const bashProgram =
@@ -19,7 +19,7 @@ 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 { PermissionResolver } from "#src/permission-resolver";
22
+ import type { ScopedPermissionResolver } from "#src/permission-resolver";
23
23
 
24
24
  import {
25
25
  makeGateCheckResult as makeCheckResult,
@@ -39,7 +39,7 @@ afterEach(() => {
39
39
  */
40
40
  async function describeGate(
41
41
  tcc: ToolCallContext,
42
- resolver: PermissionResolver,
42
+ resolver: ScopedPermissionResolver,
43
43
  ): Promise<GateResult> {
44
44
  const command = getNonEmptyString(toRecord(tcc.input).command);
45
45
  const bashProgram =