@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
@@ -4,13 +4,11 @@ import { ForwardingManager } from "#src/forwarding-manager";
4
4
 
5
5
  // ── Mocks ─────────────────────────────────────────────────────────────────
6
6
 
7
- const mockProcessForwardedPermissionRequests = vi.hoisted(() => vi.fn());
7
+ const mockProcessInbox = vi.hoisted(() =>
8
+ vi.fn((): Promise<void> => Promise.resolve()),
9
+ );
8
10
  const mockIsSubagentExecutionContext = vi.hoisted(() => vi.fn());
9
11
 
10
- vi.mock("../src/forwarded-permissions/polling", () => ({
11
- processForwardedPermissionRequests: mockProcessForwardedPermissionRequests,
12
- }));
13
-
14
12
  vi.mock("../src/subagent-context", () => ({
15
13
  isSubagentExecutionContext: mockIsSubagentExecutionContext,
16
14
  }));
@@ -27,22 +25,12 @@ function makeCtx(overrides: { hasUI?: boolean; sessionId?: string } = {}) {
27
25
  } as unknown as import("@earendil-works/pi-coding-agent").ExtensionContext;
28
26
  }
29
27
 
30
- function makeForwardingDeps() {
31
- return {
32
- forwardingDir: "/agent/sessions/permission-forwarding",
33
- subagentSessionsDir: "/agent/subagent-sessions",
34
- logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
35
- writeReviewLog: vi.fn(),
36
- requestPermissionDecisionFromUi: vi.fn(),
37
- shouldAutoApprove: vi.fn().mockReturnValue(false),
38
- } as unknown as import("../src/forwarded-permissions/polling").PermissionForwardingDeps;
28
+ function makeForwarder() {
29
+ return { processInbox: mockProcessInbox };
39
30
  }
40
31
 
41
32
  function makeManager() {
42
- return new ForwardingManager(
43
- "/agent/subagent-sessions",
44
- makeForwardingDeps(),
45
- );
33
+ return new ForwardingManager("/agent/subagent-sessions", makeForwarder());
46
34
  }
47
35
 
48
36
  // ── Tests ─────────────────────────────────────────────────────────────────
@@ -52,8 +40,8 @@ describe("ForwardingManager", () => {
52
40
  vi.useFakeTimers();
53
41
  mockIsSubagentExecutionContext.mockReset();
54
42
  mockIsSubagentExecutionContext.mockReturnValue(false);
55
- mockProcessForwardedPermissionRequests.mockReset();
56
- mockProcessForwardedPermissionRequests.mockResolvedValue(undefined);
43
+ mockProcessInbox.mockReset();
44
+ mockProcessInbox.mockResolvedValue(undefined);
57
45
  });
58
46
 
59
47
  afterEach(() => {
@@ -73,9 +61,9 @@ describe("ForwardingManager", () => {
73
61
  manager.stop();
74
62
 
75
63
  // After stop, the timer fires no more callbacks.
76
- mockProcessForwardedPermissionRequests.mockClear();
64
+ mockProcessInbox.mockClear();
77
65
  await vi.advanceTimersByTimeAsync(500);
78
- expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
66
+ expect(mockProcessInbox).not.toHaveBeenCalled();
79
67
  });
80
68
  });
81
69
 
@@ -86,7 +74,7 @@ describe("ForwardingManager", () => {
86
74
  manager.start(ctx);
87
75
 
88
76
  await vi.advanceTimersByTimeAsync(500);
89
- expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
77
+ expect(mockProcessInbox).not.toHaveBeenCalled();
90
78
  });
91
79
 
92
80
  it("stops any existing poll and does not start a new one when hasUI is false", async () => {
@@ -98,9 +86,9 @@ describe("ForwardingManager", () => {
98
86
  // Now stop the polling by calling start() with no-UI ctx.
99
87
  manager.start(noUiCtx);
100
88
 
101
- mockProcessForwardedPermissionRequests.mockClear();
89
+ mockProcessInbox.mockClear();
102
90
  await vi.advanceTimersByTimeAsync(500);
103
- expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
91
+ expect(mockProcessInbox).not.toHaveBeenCalled();
104
92
  });
105
93
 
106
94
  it("does not start polling when isSubagentExecutionContext returns true", async () => {
@@ -110,7 +98,7 @@ describe("ForwardingManager", () => {
110
98
  manager.start(ctx);
111
99
 
112
100
  await vi.advanceTimersByTimeAsync(500);
113
- expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
101
+ expect(mockProcessInbox).not.toHaveBeenCalled();
114
102
  });
115
103
 
116
104
  it("stops any existing poll when called with a subagent context", async () => {
@@ -124,21 +112,18 @@ describe("ForwardingManager", () => {
124
112
  const ctx2 = makeCtx();
125
113
  manager.start(ctx2);
126
114
 
127
- mockProcessForwardedPermissionRequests.mockClear();
115
+ mockProcessInbox.mockClear();
128
116
  await vi.advanceTimersByTimeAsync(500);
129
- expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
117
+ expect(mockProcessInbox).not.toHaveBeenCalled();
130
118
  });
131
119
 
132
- it("starts polling and calls processForwardedPermissionRequests on tick", async () => {
120
+ it("starts polling and calls processInbox on tick", async () => {
133
121
  const manager = makeManager();
134
122
  const ctx = makeCtx();
135
123
  manager.start(ctx);
136
124
 
137
125
  await vi.advanceTimersByTimeAsync(250);
138
- expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledWith(
139
- ctx,
140
- expect.anything(),
141
- );
126
+ expect(mockProcessInbox).toHaveBeenCalledWith(ctx);
142
127
  });
143
128
 
144
129
  it("is idempotent — calling start() twice does not create a second timer", async () => {
@@ -149,7 +134,7 @@ describe("ForwardingManager", () => {
149
134
 
150
135
  await vi.advanceTimersByTimeAsync(250);
151
136
  // Only one tick should fire per interval, not two.
152
- expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(1);
137
+ expect(mockProcessInbox).toHaveBeenCalledTimes(1);
153
138
  });
154
139
 
155
140
  it("updates the context when called again while already running", async () => {
@@ -161,16 +146,13 @@ describe("ForwardingManager", () => {
161
146
 
162
147
  await vi.advanceTimersByTimeAsync(250);
163
148
  // The process call should use the newer context.
164
- expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledWith(
165
- ctx2,
166
- expect.anything(),
167
- );
149
+ expect(mockProcessInbox).toHaveBeenCalledWith(ctx2);
168
150
  });
169
151
 
170
152
  it("skips a tick while processing is in progress", async () => {
171
- // Make processForwardedPermissionRequests hang so processing=true persists.
153
+ // Make processInbox hang so processing=true persists.
172
154
  let resolveProcess: () => void;
173
- mockProcessForwardedPermissionRequests.mockReturnValue(
155
+ mockProcessInbox.mockReturnValue(
174
156
  new Promise<void>((resolve) => {
175
157
  resolveProcess = resolve;
176
158
  }),
@@ -182,22 +164,22 @@ describe("ForwardingManager", () => {
182
164
 
183
165
  // First tick starts processing.
184
166
  await vi.advanceTimersByTimeAsync(250);
185
- expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(1);
167
+ expect(mockProcessInbox).toHaveBeenCalledTimes(1);
186
168
 
187
169
  // Second tick is skipped because processing flag is still true.
188
170
  await vi.advanceTimersByTimeAsync(250);
189
- expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(1);
171
+ expect(mockProcessInbox).toHaveBeenCalledTimes(1);
190
172
 
191
173
  // Resolve and a third tick should fire.
192
174
  resolveProcess!();
193
175
  await vi.advanceTimersByTimeAsync(250);
194
- expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(2);
176
+ expect(mockProcessInbox).toHaveBeenCalledTimes(2);
195
177
  });
196
178
 
197
179
  it("passes subagentSessionsDir from the constructor to isSubagentExecutionContext", () => {
198
180
  const manager = new ForwardingManager(
199
181
  "/custom/subagent-dir",
200
- makeForwardingDeps(),
182
+ makeForwarder(),
201
183
  );
202
184
  const ctx = makeCtx();
203
185
  manager.start(ctx);
@@ -1,13 +1,13 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
 
3
+ import type { AgentPrepSession } from "#src/agent-prep-session";
3
4
  import {
4
5
  AgentPrepHandler,
5
6
  shouldExposeTool,
6
7
  } from "#src/handlers/before-agent-start";
7
- import type { PermissionSession } from "#src/permission-session";
8
8
  import type { ToolRegistry } from "#src/tool-registry";
9
9
 
10
- import { makeCtx } from "#test/helpers/handler-fixtures";
10
+ import { makeCheckResult, makeCtx } from "#test/helpers/handler-fixtures";
11
11
 
12
12
  // ── SDK stubs ──────────────────────────────────────────────────────────────
13
13
  vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
@@ -26,24 +26,48 @@ function makeEvent(systemPrompt = "You are an assistant.") {
26
26
  }
27
27
 
28
28
  function makeSession(
29
- overrides: Partial<Record<keyof PermissionSession, unknown>> = {},
30
- ): PermissionSession {
29
+ overrides: Partial<AgentPrepSession> = {},
30
+ ): AgentPrepSession {
31
31
  return {
32
- logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
33
- activate: vi.fn(),
34
- refreshConfig: vi.fn(),
35
- resolveAgentName: vi.fn().mockReturnValue(null),
36
- getToolPermission: vi.fn().mockReturnValue("allow"),
37
- checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
38
- shouldUpdateActiveTools: vi.fn().mockReturnValue(true),
39
- commitActiveToolsCacheKey: vi.fn(),
40
- getPolicyCacheStamp: vi.fn().mockReturnValue("stamp-1"),
41
- shouldUpdatePromptState: vi.fn().mockReturnValue(true),
42
- commitPromptStateCacheKey: vi.fn(),
43
- setActiveSkillEntries: vi.fn(),
44
- getActiveSkillEntries: vi.fn().mockReturnValue([]),
45
- ...overrides,
46
- } as unknown as PermissionSession;
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
+ };
47
71
  }
48
72
 
49
73
  function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
@@ -55,11 +79,11 @@ function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
55
79
  }
56
80
 
57
81
  function makeHandler(overrides?: {
58
- session?: Partial<Record<keyof PermissionSession, unknown>>;
82
+ session?: Partial<AgentPrepSession>;
59
83
  toolRegistry?: Partial<ToolRegistry>;
60
84
  }): {
61
85
  handler: AgentPrepHandler;
62
- session: PermissionSession;
86
+ session: AgentPrepSession;
63
87
  toolRegistry: ToolRegistry;
64
88
  } {
65
89
  const session = makeSession(overrides?.session);
@@ -8,18 +8,25 @@
8
8
  * Regression guard: importing the four external-directory message helpers
9
9
  * ensures the test file fails to load if any helper is removed.
10
10
  */
11
+
12
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
13
  import { describe, expect, it, vi } from "vitest";
12
14
 
15
+ import { GateDecisionReporter } from "#src/decision-reporter";
13
16
  import { EXTENSION_TAG } from "#src/denial-messages";
14
17
  import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
15
18
  import { formatExternalDirectoryAskPrompt } from "#src/handlers/gates/external-directory-messages";
19
+ import { GateRunner } from "#src/handlers/gates/runner";
20
+ import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
21
+ import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
16
22
  import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
17
- import type { PermissionSession } from "#src/permission-session";
23
+ import { resolveToolPreviewLimits } from "#src/tool-preview-formatter";
18
24
  import type { ToolRegistry } from "#src/tool-registry";
19
25
  import type { PermissionCheckResult, PermissionState } from "#src/types";
20
26
 
21
27
  import {
22
28
  getDecisionEvents,
29
+ type MockGateHandlerSession,
23
30
  makeCtx,
24
31
  makeEvents,
25
32
  makeToolCallEvent,
@@ -73,24 +80,71 @@ function makeCheckPermission(
73
80
  }
74
81
 
75
82
  function makeSession(
76
- overrides: Partial<Record<keyof PermissionSession, unknown>> = {},
77
- ): PermissionSession {
78
- return {
79
- logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
80
- activate: vi.fn(),
81
- resolveAgentName: vi.fn().mockReturnValue(null),
82
- checkPermission: makeCheckPermission("deny"),
83
- getToolPermission: vi.fn().mockReturnValue("allow"),
84
- getSessionRuleset: vi.fn().mockReturnValue([]),
85
- recordSessionApproval: vi.fn(),
86
- getActiveSkillEntries: vi.fn().mockReturnValue([]),
87
- getInfrastructureDirs: vi.fn().mockReturnValue([]),
88
- getInfrastructureReadPaths: vi.fn().mockReturnValue([]),
89
- config: DEFAULT_EXTENSION_CONFIG,
90
- canPrompt: vi.fn().mockReturnValue(true),
91
- prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
92
- ...overrides,
93
- } as unknown as PermissionSession;
83
+ overrides: Partial<MockGateHandlerSession> = {},
84
+ ): MockGateHandlerSession {
85
+ const session: MockGateHandlerSession = {
86
+ logger: overrides.logger ?? {
87
+ debug: vi.fn(),
88
+ review: vi.fn(),
89
+ warn: vi.fn(),
90
+ },
91
+ activate: overrides.activate ?? vi.fn<MockGateHandlerSession["activate"]>(),
92
+ resolveAgentName:
93
+ overrides.resolveAgentName ??
94
+ vi.fn<MockGateHandlerSession["resolveAgentName"]>().mockReturnValue(null),
95
+ checkPermission: overrides.checkPermission ?? makeCheckPermission("deny"),
96
+ getSessionRuleset:
97
+ overrides.getSessionRuleset ??
98
+ vi.fn<MockGateHandlerSession["getSessionRuleset"]>().mockReturnValue([]),
99
+ recordSessionApproval:
100
+ overrides.recordSessionApproval ??
101
+ vi.fn<MockGateHandlerSession["recordSessionApproval"]>(),
102
+ getActiveSkillEntries:
103
+ overrides.getActiveSkillEntries ??
104
+ vi
105
+ .fn<MockGateHandlerSession["getActiveSkillEntries"]>()
106
+ .mockReturnValue([]),
107
+ getInfrastructureReadDirs:
108
+ overrides.getInfrastructureReadDirs ??
109
+ vi
110
+ .fn<MockGateHandlerSession["getInfrastructureReadDirs"]>()
111
+ .mockReturnValue([]),
112
+ getToolPreviewLimits:
113
+ overrides.getToolPreviewLimits ??
114
+ vi
115
+ .fn<MockGateHandlerSession["getToolPreviewLimits"]>()
116
+ .mockReturnValue(resolveToolPreviewLimits(DEFAULT_EXTENSION_CONFIG)),
117
+ canPrompt:
118
+ overrides.canPrompt ??
119
+ vi.fn<MockGateHandlerSession["canPrompt"]>().mockReturnValue(true),
120
+ prompt:
121
+ overrides.prompt ??
122
+ vi
123
+ .fn<MockGateHandlerSession["prompt"]>()
124
+ .mockResolvedValue({ approved: true, state: "approved" }),
125
+ // Delegations — closures read `session` at call time so overrides win.
126
+ resolve:
127
+ overrides.resolve ??
128
+ vi.fn<MockGateHandlerSession["resolve"]>((surface, input, agentName) =>
129
+ session.checkPermission(
130
+ surface,
131
+ input,
132
+ agentName,
133
+ session.getSessionRuleset(),
134
+ ),
135
+ ),
136
+ canConfirm:
137
+ overrides.canConfirm ??
138
+ vi.fn<MockGateHandlerSession["canConfirm"]>(() =>
139
+ session.canPrompt(undefined as unknown as ExtensionContext),
140
+ ),
141
+ promptPermission:
142
+ overrides.promptPermission ??
143
+ vi.fn<MockGateHandlerSession["promptPermission"]>((details) =>
144
+ session.prompt(undefined as unknown as ExtensionContext, details),
145
+ ),
146
+ };
147
+ return session;
94
148
  }
95
149
 
96
150
  /** All PATH_BEARING_TOOLS members. */
@@ -112,17 +166,27 @@ function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
112
166
  }
113
167
 
114
168
  function makeHandler(overrides?: {
115
- session?: Partial<Record<keyof PermissionSession, unknown>>;
169
+ session?: Partial<MockGateHandlerSession>;
116
170
  toolRegistry?: Partial<ToolRegistry>;
117
171
  }): {
118
172
  handler: PermissionGateHandler;
119
173
  events: ReturnType<typeof makeEvents>;
120
- session: PermissionSession;
174
+ session: MockGateHandlerSession;
121
175
  } {
122
176
  const session = makeSession(overrides?.session);
123
177
  const events = makeEvents();
124
178
  const toolRegistry = makeToolRegistry(overrides?.toolRegistry);
125
- const handler = new PermissionGateHandler(session, events, toolRegistry);
179
+ const pipeline = new ToolCallGatePipeline(session);
180
+ const skillInputPipeline = new SkillInputGatePipeline(session);
181
+ const reporter = new GateDecisionReporter(session.logger, events);
182
+ const runner = new GateRunner(session, session, session, reporter);
183
+ const handler = new PermissionGateHandler(
184
+ session,
185
+ toolRegistry,
186
+ pipeline,
187
+ skillInputPipeline,
188
+ runner,
189
+ );
126
190
  return { handler, events, session };
127
191
  }
128
192
 
@@ -8,18 +8,29 @@
8
8
  * the real interaction between PermissionSession, SessionRules, and
9
9
  * PermissionManager.
10
10
  */
11
+
12
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
13
  import { describe, expect, it, vi } from "vitest";
12
14
 
15
+ import { GateDecisionReporter } from "#src/decision-reporter";
13
16
  import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
17
+ import { GateRunner } from "#src/handlers/gates/runner";
18
+ import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
19
+ import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
14
20
  import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
15
- import type { PermissionSession } from "#src/permission-session";
21
+ import type { PromptPermissionDetails } from "#src/permission-prompter";
16
22
  import type { Rule } from "#src/rule";
17
23
  import type { SessionApproval } from "#src/session-approval";
24
+ import { resolveToolPreviewLimits } from "#src/tool-preview-formatter";
18
25
  import type { ToolRegistry } from "#src/tool-registry";
19
26
  import type { PermissionCheckResult } from "#src/types";
20
27
  import { wildcardMatch } from "#src/wildcard-matcher";
21
28
 
22
- import { makeCtx, makeEvents } from "#test/helpers/handler-fixtures";
29
+ import {
30
+ type MockGateHandlerSession,
31
+ makeCtx,
32
+ makeEvents,
33
+ } from "#test/helpers/handler-fixtures";
23
34
 
24
35
  // ── SDK stub ───────────────────────────────────────────────────────────────
25
36
  vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
@@ -39,12 +50,12 @@ vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
39
50
  * "allow" by default.
40
51
  */
41
52
  function makeStatefulSession(
42
- overrides: Partial<Record<keyof PermissionSession, unknown>> = {},
43
- ): PermissionSession {
53
+ overrides: Partial<MockGateHandlerSession> = {},
54
+ ): MockGateHandlerSession {
44
55
  const sessionRules: Rule[] = [];
45
56
 
46
57
  const checkPermission = vi
47
- .fn()
58
+ .fn<MockGateHandlerSession["checkPermission"]>()
48
59
  .mockImplementation(
49
60
  (
50
61
  surface: string,
@@ -97,7 +108,7 @@ function makeStatefulSession(
97
108
  );
98
109
 
99
110
  const recordSessionApproval = vi
100
- .fn()
111
+ .fn<MockGateHandlerSession["recordSessionApproval"]>()
101
112
  .mockImplementation((approval: SessionApproval) => {
102
113
  for (const pattern of approval.patterns) {
103
114
  sessionRules.push({
@@ -110,26 +121,86 @@ function makeStatefulSession(
110
121
  }
111
122
  });
112
123
 
113
- const getSessionRuleset = vi.fn().mockImplementation(() => [...sessionRules]);
124
+ const getSessionRuleset = vi
125
+ .fn<MockGateHandlerSession["getSessionRuleset"]>()
126
+ .mockImplementation(() => [...sessionRules]);
127
+
128
+ const session: MockGateHandlerSession = {
129
+ logger: overrides.logger ?? {
130
+ debug: vi.fn(),
131
+ review: vi.fn(),
132
+ warn: vi.fn(),
133
+ },
134
+ activate: overrides.activate ?? vi.fn<MockGateHandlerSession["activate"]>(),
135
+ resolveAgentName:
136
+ overrides.resolveAgentName ??
137
+ vi.fn<MockGateHandlerSession["resolveAgentName"]>().mockReturnValue(null),
138
+ checkPermission: overrides.checkPermission ?? checkPermission,
139
+ getSessionRuleset: overrides.getSessionRuleset ?? getSessionRuleset,
140
+ recordSessionApproval:
141
+ overrides.recordSessionApproval ?? recordSessionApproval,
142
+ getActiveSkillEntries:
143
+ overrides.getActiveSkillEntries ??
144
+ vi
145
+ .fn<MockGateHandlerSession["getActiveSkillEntries"]>()
146
+ .mockReturnValue([]),
147
+ getInfrastructureReadDirs:
148
+ overrides.getInfrastructureReadDirs ??
149
+ vi
150
+ .fn<MockGateHandlerSession["getInfrastructureReadDirs"]>()
151
+ .mockReturnValue([]),
152
+ getToolPreviewLimits:
153
+ overrides.getToolPreviewLimits ??
154
+ vi
155
+ .fn<MockGateHandlerSession["getToolPreviewLimits"]>()
156
+ .mockReturnValue(resolveToolPreviewLimits(DEFAULT_EXTENSION_CONFIG)),
157
+ canPrompt:
158
+ overrides.canPrompt ??
159
+ vi.fn<MockGateHandlerSession["canPrompt"]>().mockReturnValue(true),
160
+ prompt:
161
+ overrides.prompt ??
162
+ vi
163
+ .fn<MockGateHandlerSession["prompt"]>()
164
+ .mockResolvedValue({ approved: true, state: "approved_for_session" }),
165
+ // Delegations — closures read `session` at call time so overrides win.
166
+ resolve:
167
+ overrides.resolve ??
168
+ vi.fn<MockGateHandlerSession["resolve"]>((surface, input, agentName) =>
169
+ session.checkPermission(
170
+ surface,
171
+ input,
172
+ agentName,
173
+ session.getSessionRuleset(),
174
+ ),
175
+ ),
176
+ canConfirm:
177
+ overrides.canConfirm ??
178
+ vi.fn<MockGateHandlerSession["canConfirm"]>(() =>
179
+ session.canPrompt(undefined as unknown as ExtensionContext),
180
+ ),
181
+ promptPermission:
182
+ overrides.promptPermission ??
183
+ vi.fn<MockGateHandlerSession["promptPermission"]>(
184
+ (details: PromptPermissionDetails) =>
185
+ session.prompt(undefined as unknown as ExtensionContext, details),
186
+ ),
187
+ };
188
+ return session;
189
+ }
114
190
 
115
- return {
116
- logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
117
- activate: vi.fn(),
118
- resolveAgentName: vi.fn().mockReturnValue(null),
119
- checkPermission,
120
- getToolPermission: vi.fn().mockReturnValue("allow"),
121
- getSessionRuleset,
122
- recordSessionApproval,
123
- getActiveSkillEntries: vi.fn().mockReturnValue([]),
124
- getInfrastructureDirs: vi.fn().mockReturnValue([]),
125
- getInfrastructureReadPaths: vi.fn().mockReturnValue([]),
126
- config: DEFAULT_EXTENSION_CONFIG,
127
- canPrompt: vi.fn().mockReturnValue(true),
128
- prompt: vi
129
- .fn()
130
- .mockResolvedValue({ approved: true, state: "approved_for_session" }),
131
- ...overrides,
132
- } as unknown as PermissionSession;
191
+ function makeHandlerForSession(
192
+ session: MockGateHandlerSession,
193
+ ): PermissionGateHandler {
194
+ const events = makeEvents();
195
+ const reporter = new GateDecisionReporter(session.logger, events);
196
+ const runner = new GateRunner(session, session, session, reporter);
197
+ return new PermissionGateHandler(
198
+ session,
199
+ makeToolRegistry(),
200
+ new ToolCallGatePipeline(session),
201
+ new SkillInputGatePipeline(session),
202
+ runner,
203
+ );
133
204
  }
134
205
 
135
206
  function makeToolRegistry(): ToolRegistry {
@@ -152,11 +223,7 @@ describe("external-directory session dedup", () => {
152
223
  describe("path-bearing tools (read, write, edit)", () => {
153
224
  it("does not re-prompt for the same external path after session approval", async () => {
154
225
  const session = makeStatefulSession();
155
- const handler = new PermissionGateHandler(
156
- session,
157
- makeEvents(),
158
- makeToolRegistry(),
159
- );
226
+ const handler = makeHandlerForSession(session);
160
227
  const ctx = makeCtx();
161
228
  const externalPath = "/outside/project/data.txt";
162
229
 
@@ -185,11 +252,7 @@ describe("external-directory session dedup", () => {
185
252
 
186
253
  it("does not re-prompt for a different file in the same external directory", async () => {
187
254
  const session = makeStatefulSession();
188
- const handler = new PermissionGateHandler(
189
- session,
190
- makeEvents(),
191
- makeToolRegistry(),
192
- );
255
+ const handler = makeHandlerForSession(session);
193
256
  const ctx = makeCtx();
194
257
 
195
258
  // First call — prompt for /outside/project/a.txt
@@ -215,11 +278,7 @@ describe("external-directory session dedup", () => {
215
278
 
216
279
  it("does prompt for a file in a different external directory", async () => {
217
280
  const session = makeStatefulSession();
218
- const handler = new PermissionGateHandler(
219
- session,
220
- makeEvents(),
221
- makeToolRegistry(),
222
- );
281
+ const handler = makeHandlerForSession(session);
223
282
  const ctx = makeCtx();
224
283
 
225
284
  // First call — /outside/alpha/file.txt
@@ -249,11 +308,7 @@ describe("external-directory session dedup", () => {
249
308
  .fn()
250
309
  .mockResolvedValue({ approved: true, state: "approved" }),
251
310
  });
252
- const handler = new PermissionGateHandler(
253
- session,
254
- makeEvents(),
255
- makeToolRegistry(),
256
- );
311
+ const handler = makeHandlerForSession(session);
257
312
  const ctx = makeCtx();
258
313
  const externalPath = "/outside/project/data.txt";
259
314
 
@@ -282,11 +337,7 @@ describe("external-directory session dedup", () => {
282
337
  describe("bash commands with external paths", () => {
283
338
  it("does not re-prompt for a bash command referencing the same external path after session approval", async () => {
284
339
  const session = makeStatefulSession();
285
- const handler = new PermissionGateHandler(
286
- session,
287
- makeEvents(),
288
- makeToolRegistry(),
289
- );
340
+ const handler = makeHandlerForSession(session);
290
341
  const ctx = makeCtx();
291
342
 
292
343
  // First call — bash referencing /tmp/out.txt
@@ -314,11 +365,7 @@ describe("external-directory session dedup", () => {
314
365
 
315
366
  it("does not re-prompt for read after bash already approved the same directory", async () => {
316
367
  const session = makeStatefulSession();
317
- const handler = new PermissionGateHandler(
318
- session,
319
- makeEvents(),
320
- makeToolRegistry(),
321
- );
368
+ const handler = makeHandlerForSession(session);
322
369
  const ctx = makeCtx();
323
370
 
324
371
  // First call — bash writes to /tmp/out.txt