@gotgenes/pi-permission-system 10.0.0 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/agent-prep-session.ts +28 -0
  5. package/src/decision-reporter.ts +41 -0
  6. package/src/denial-messages.ts +11 -0
  7. package/src/forwarded-permissions/permission-forwarder.ts +549 -0
  8. package/src/forwarding-manager.ts +3 -7
  9. package/src/gate-handler-session.ts +13 -0
  10. package/src/gate-prompter.ts +14 -0
  11. package/src/handlers/before-agent-start.ts +2 -3
  12. package/src/handlers/gates/bash-command.ts +4 -18
  13. package/src/handlers/gates/bash-external-directory.ts +3 -15
  14. package/src/handlers/gates/bash-path.ts +3 -16
  15. package/src/handlers/gates/descriptor.ts +0 -28
  16. package/src/handlers/gates/path.ts +3 -15
  17. package/src/handlers/gates/runner.ts +142 -105
  18. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  19. package/src/handlers/gates/skill-input.ts +44 -0
  20. package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
  21. package/src/handlers/lifecycle.ts +9 -9
  22. package/src/handlers/permission-gate-handler.ts +34 -238
  23. package/src/index.ts +53 -69
  24. package/src/mcp-targets.ts +56 -46
  25. package/src/permission-manager.ts +69 -3
  26. package/src/permission-prompter.ts +7 -58
  27. package/src/permission-resolver.ts +17 -0
  28. package/src/permission-session.ts +83 -27
  29. package/src/permissions-service.ts +53 -0
  30. package/src/runtime.ts +1 -37
  31. package/src/service-lifecycle.ts +49 -0
  32. package/src/session-approval-recorder.ts +6 -0
  33. package/src/session-lifecycle-session.ts +24 -0
  34. package/src/tool-input-preview.ts +0 -62
  35. package/src/tool-input-prompt-formatters.ts +63 -0
  36. package/src/tool-preview-formatter.ts +6 -4
  37. package/test/decision-reporter.test.ts +112 -0
  38. package/test/denial-messages.test.ts +62 -0
  39. package/test/forwarding-manager.test.ts +26 -44
  40. package/test/handlers/before-agent-start.test.ts +45 -21
  41. package/test/handlers/external-directory-integration.test.ts +83 -114
  42. package/test/handlers/external-directory-session-dedup.test.ts +102 -55
  43. package/test/handlers/gates/bash-command.test.ts +49 -90
  44. package/test/handlers/gates/bash-external-directory.test.ts +54 -95
  45. package/test/handlers/gates/bash-path.test.ts +54 -157
  46. package/test/handlers/gates/path.test.ts +38 -105
  47. package/test/handlers/gates/runner.test.ts +151 -186
  48. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  49. package/test/handlers/gates/skill-input.test.ts +128 -0
  50. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
  51. package/test/handlers/input.test.ts +1 -2
  52. package/test/handlers/lifecycle.test.ts +49 -33
  53. package/test/handlers/tool-call-events.test.ts +1 -1
  54. package/test/handlers/tool-call.test.ts +44 -153
  55. package/test/helpers/gate-fixtures.ts +212 -17
  56. package/test/helpers/handler-fixtures.ts +226 -29
  57. package/test/mcp-targets.test.ts +55 -0
  58. package/test/permission-forwarder.test.ts +295 -0
  59. package/test/permission-forwarding.test.ts +0 -282
  60. package/test/permission-manager-unified.test.ts +159 -1
  61. package/test/permission-prompter.test.ts +33 -44
  62. package/test/permission-session.test.ts +211 -105
  63. package/test/permissions-service.test.ts +151 -0
  64. package/test/runtime.test.ts +2 -86
  65. package/test/service-lifecycle.test.ts +162 -0
  66. package/test/tool-input-preview.test.ts +0 -111
  67. package/test/tool-input-prompt-formatters.test.ts +115 -0
  68. package/src/forwarded-permissions/polling.ts +0 -411
@@ -7,13 +7,66 @@
7
7
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
8
  import { vi } from "vitest";
9
9
 
10
+ import { GateDecisionReporter } from "#src/decision-reporter";
10
11
  import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
12
+ import type { GateHandlerSession } from "#src/gate-handler-session";
13
+ import type { GatePrompter } from "#src/gate-prompter";
14
+ import { GateRunner } from "#src/handlers/gates/runner";
15
+ import {
16
+ type SkillInputGateInputs,
17
+ SkillInputGatePipeline,
18
+ } from "#src/handlers/gates/skill-input-gate-pipeline";
19
+ import {
20
+ type ToolCallGateInputs,
21
+ ToolCallGatePipeline,
22
+ } from "#src/handlers/gates/tool-call-gate-pipeline";
11
23
  import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
24
+ import type { PermissionPromptDecision } from "#src/permission-dialog";
12
25
  import type { PermissionDecisionEvent } from "#src/permission-events";
13
26
  import { PERMISSIONS_DECISION_CHANNEL } from "#src/permission-events";
14
- import type { PermissionSession } from "#src/permission-session";
27
+ import type { PromptPermissionDetails } from "#src/permission-prompter";
28
+ import type { Rule } from "#src/rule";
29
+ import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
30
+ import type { SessionLogger } from "#src/session-logger";
31
+ import { resolveToolPreviewLimits } from "#src/tool-preview-formatter";
15
32
  import type { ToolRegistry } from "#src/tool-registry";
16
- import type { PermissionCheckResult } from "#src/types";
33
+ import type { PermissionCheckResult, PermissionState } from "#src/types";
34
+
35
+ /**
36
+ * Precise mock boundary for PermissionGateHandler integration tests.
37
+ *
38
+ * Intersection of every role the handler and its collaborators require,
39
+ * plus the context-bound prompting helpers that GatePrompter delegates to.
40
+ * Without a cast, TypeScript enforces this at the call sites where the
41
+ * mock is passed to GateRunner / ToolCallGatePipeline / PermissionGateHandler.
42
+ *
43
+ * The 4-arg `checkPermission` overrides the 3-arg version from
44
+ * GateHandlerSession so the `resolve` delegation can forward session rules.
45
+ */
46
+ export type MockGateHandlerSession = ToolCallGateInputs &
47
+ SkillInputGateInputs &
48
+ SessionApprovalRecorder &
49
+ GatePrompter &
50
+ GateHandlerSession & {
51
+ /** Logger source for the reporter the fixture builds. */
52
+ logger: SessionLogger;
53
+ /** Session-rule accessor — used by the resolve delegation. */
54
+ getSessionRuleset(): Rule[];
55
+ /** 4-arg form so the resolve delegation can pass rules. */
56
+ checkPermission(
57
+ surface: string,
58
+ input: unknown,
59
+ agentName?: string,
60
+ rules?: Rule[],
61
+ ): PermissionCheckResult;
62
+ /** Context-bound canPrompt — overriding this steers canConfirm. */
63
+ canPrompt(ctx: ExtensionContext): boolean;
64
+ /** Context-bound prompt — overriding this steers promptPermission. */
65
+ prompt(
66
+ ctx: ExtensionContext,
67
+ details: PromptPermissionDetails,
68
+ ): Promise<PermissionPromptDecision>;
69
+ };
17
70
 
18
71
  export function makeEvents() {
19
72
  return {
@@ -75,33 +128,86 @@ export function makeCheckResult(
75
128
  }
76
129
 
77
130
  /**
78
- * Full-union session stub.
131
+ * Full-intersection session stub.
132
+ *
133
+ * Uses per-field `??` selection (no spread) so TypeScript verifies every
134
+ * field against `MockGateHandlerSession` individually — a missing field fails
135
+ * `pnpm run check` instead of failing silently at runtime.
79
136
  *
80
- * Includes every method mocked across handler test files so each file
81
- * only needs to override the fields that differ from the defaults.
137
+ * The `resolve`, `canConfirm`, and `promptPermission` delegations are inlined
138
+ * as closures that read `session` at call time, so overriding `checkPermission`,
139
+ * `canPrompt`, or `prompt` automatically steers them without extra guards.
82
140
  */
83
141
  export function makeSession(
84
- overrides: Partial<Record<keyof PermissionSession, unknown>> = {},
85
- ): PermissionSession {
86
- return {
87
- logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
88
- activate: vi.fn(),
89
- resolveAgentName: vi.fn().mockReturnValue(null),
90
- checkPermission: vi.fn().mockReturnValue(makeCheckResult()),
91
- getToolPermission: vi.fn().mockReturnValue("allow"),
92
- getSessionRuleset: vi.fn().mockReturnValue([]),
93
- recordSessionApproval: vi.fn(),
94
- getActiveSkillEntries: vi.fn().mockReturnValue([]),
95
- getInfrastructureDirs: vi
96
- .fn()
97
- .mockReturnValue(["/test/agent", "/test/agent/git"]),
98
- getInfrastructureReadPaths: vi.fn().mockReturnValue([]),
99
- config: DEFAULT_EXTENSION_CONFIG,
100
- canPrompt: vi.fn().mockReturnValue(true),
101
- prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
102
- createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
103
- ...overrides,
104
- } as unknown as PermissionSession;
142
+ overrides: Partial<MockGateHandlerSession> = {},
143
+ ): MockGateHandlerSession {
144
+ const session: MockGateHandlerSession = {
145
+ logger: overrides.logger ?? {
146
+ debug: vi.fn(),
147
+ review: vi.fn(),
148
+ warn: vi.fn(),
149
+ },
150
+ activate: overrides.activate ?? vi.fn<MockGateHandlerSession["activate"]>(),
151
+ resolveAgentName:
152
+ overrides.resolveAgentName ??
153
+ vi.fn<MockGateHandlerSession["resolveAgentName"]>().mockReturnValue(null),
154
+ checkPermission:
155
+ overrides.checkPermission ??
156
+ vi
157
+ .fn<MockGateHandlerSession["checkPermission"]>()
158
+ .mockReturnValue(makeCheckResult()),
159
+ getSessionRuleset:
160
+ overrides.getSessionRuleset ??
161
+ vi.fn<MockGateHandlerSession["getSessionRuleset"]>().mockReturnValue([]),
162
+ recordSessionApproval:
163
+ overrides.recordSessionApproval ??
164
+ vi.fn<MockGateHandlerSession["recordSessionApproval"]>(),
165
+ getActiveSkillEntries:
166
+ overrides.getActiveSkillEntries ??
167
+ vi
168
+ .fn<MockGateHandlerSession["getActiveSkillEntries"]>()
169
+ .mockReturnValue([]),
170
+ getInfrastructureReadDirs:
171
+ overrides.getInfrastructureReadDirs ??
172
+ vi
173
+ .fn<MockGateHandlerSession["getInfrastructureReadDirs"]>()
174
+ .mockReturnValue(["/test/agent", "/test/agent/git"]),
175
+ getToolPreviewLimits:
176
+ overrides.getToolPreviewLimits ??
177
+ vi
178
+ .fn<MockGateHandlerSession["getToolPreviewLimits"]>()
179
+ .mockReturnValue(resolveToolPreviewLimits(DEFAULT_EXTENSION_CONFIG)),
180
+ canPrompt:
181
+ overrides.canPrompt ??
182
+ vi.fn<MockGateHandlerSession["canPrompt"]>().mockReturnValue(true),
183
+ prompt:
184
+ overrides.prompt ??
185
+ vi
186
+ .fn<MockGateHandlerSession["prompt"]>()
187
+ .mockResolvedValue({ approved: true, state: "approved" }),
188
+ // Delegations — closures read `session` at call time so overrides win.
189
+ resolve:
190
+ overrides.resolve ??
191
+ vi.fn<MockGateHandlerSession["resolve"]>((surface, input, agentName) =>
192
+ session.checkPermission(
193
+ surface,
194
+ input,
195
+ agentName,
196
+ session.getSessionRuleset(),
197
+ ),
198
+ ),
199
+ canConfirm:
200
+ overrides.canConfirm ??
201
+ vi.fn<MockGateHandlerSession["canConfirm"]>(() =>
202
+ session.canPrompt(undefined as unknown as ExtensionContext),
203
+ ),
204
+ promptPermission:
205
+ overrides.promptPermission ??
206
+ vi.fn<MockGateHandlerSession["promptPermission"]>((details) =>
207
+ session.prompt(undefined as unknown as ExtensionContext, details),
208
+ ),
209
+ };
210
+ return session;
105
211
  }
106
212
 
107
213
  export function makeToolRegistry(
@@ -114,6 +220,78 @@ export function makeToolRegistry(
114
220
  };
115
221
  }
116
222
 
223
+ /**
224
+ * Surface-dispatching `checkPermission` mock.
225
+ *
226
+ * Builds a `vi.fn()` that returns a `PermissionCheckResult` for each surface,
227
+ * using `bySurface[surface]` when matched and `defaultResult` otherwise.
228
+ * Default fields: `toolName` = the surface string, `source: "tool"`,
229
+ * `origin: "builtin"` — callers override by including the field in the
230
+ * per-surface or default partial (e.g. `{ path: { state: "allow", source: "special" } }`).
231
+ *
232
+ * Return type is intentionally unannotated so callers retain full `vi.fn()`
233
+ * mock access (`mock.calls`, `toHaveBeenCalledWith`, etc.).
234
+ */
235
+ export function makeSurfaceCheck(
236
+ bySurface: Record<
237
+ string,
238
+ Partial<PermissionCheckResult> & { state: PermissionState }
239
+ >,
240
+ defaultResult: Partial<PermissionCheckResult> & { state: PermissionState } = {
241
+ state: "allow",
242
+ },
243
+ ) {
244
+ return vi
245
+ .fn<MockGateHandlerSession["checkPermission"]>()
246
+ .mockImplementation((surface): PermissionCheckResult => {
247
+ const base = bySurface[surface] ?? defaultResult;
248
+ return {
249
+ toolName: surface,
250
+ source: "tool",
251
+ origin: "builtin",
252
+ ...base,
253
+ };
254
+ });
255
+ }
256
+
257
+ /**
258
+ * Bash-surface `checkPermission` mock that dispatches on a command regex.
259
+ *
260
+ * For the `bash` surface: returns a deny result when `opts.deny` matches the
261
+ * command, and an allow result otherwise. For all other surfaces, returns a
262
+ * plain allow result.
263
+ *
264
+ * Return type is intentionally unannotated so callers retain full `vi.fn()`
265
+ * mock access.
266
+ */
267
+ export function makeBashCommandCheck(opts: {
268
+ deny: RegExp;
269
+ denyMatched: string;
270
+ allowMatched?: string;
271
+ }) {
272
+ return vi
273
+ .fn<MockGateHandlerSession["checkPermission"]>()
274
+ .mockImplementation((surface, input): PermissionCheckResult => {
275
+ if (surface === "bash") {
276
+ const command = (input as { command?: string }).command ?? "";
277
+ return opts.deny.test(command)
278
+ ? makeCheckResult({
279
+ state: "deny",
280
+ source: "bash",
281
+ command,
282
+ matchedPattern: opts.denyMatched,
283
+ })
284
+ : makeCheckResult({
285
+ state: "allow",
286
+ source: "bash",
287
+ command,
288
+ matchedPattern: opts.allowMatched,
289
+ });
290
+ }
291
+ return makeCheckResult({ state: "allow" });
292
+ });
293
+ }
294
+
117
295
  /**
118
296
  * Constructs a PermissionGateHandler with mocked collaborators.
119
297
  *
@@ -121,13 +299,32 @@ export function makeToolRegistry(
121
299
  * it needs — handler, events, session, and toolRegistry are all available.
122
300
  */
123
301
  export function makeHandler(overrides?: {
124
- session?: Partial<Record<keyof PermissionSession, unknown>>;
302
+ session?: Partial<MockGateHandlerSession>;
125
303
  toolRegistry?: Partial<ToolRegistry>;
304
+ /** Sugar: builds the `getAll` mock from a list of tool names. */
305
+ tools?: string[];
126
306
  }) {
127
307
  const session = makeSession(overrides?.session);
128
308
  const events = makeEvents();
129
- const toolRegistry = makeToolRegistry(overrides?.toolRegistry);
130
- const handler = new PermissionGateHandler(session, events, toolRegistry);
309
+ const toolRegistry =
310
+ overrides?.tools !== undefined
311
+ ? makeToolRegistry({
312
+ getAll: vi
313
+ .fn()
314
+ .mockReturnValue(overrides.tools.map((name) => ({ name }))),
315
+ })
316
+ : makeToolRegistry(overrides?.toolRegistry);
317
+ const pipeline = new ToolCallGatePipeline(session);
318
+ const skillInputPipeline = new SkillInputGatePipeline(session);
319
+ const reporter = new GateDecisionReporter(session.logger, events);
320
+ const runner = new GateRunner(session, session, session, reporter);
321
+ const handler = new PermissionGateHandler(
322
+ session,
323
+ toolRegistry,
324
+ pipeline,
325
+ skillInputPipeline,
326
+ runner,
327
+ );
131
328
  return { handler, events, session, toolRegistry };
132
329
  }
133
330
 
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  createMcpPermissionTargets,
4
+ McpTargetList,
4
5
  parseQualifiedMcpToolName,
5
6
  } from "#src/mcp-targets";
6
7
 
@@ -176,3 +177,57 @@ describe("createMcpPermissionTargets", () => {
176
177
  });
177
178
  });
178
179
  });
180
+
181
+ describe("McpTargetList", () => {
182
+ describe("add", () => {
183
+ it("ignores null", () => {
184
+ const list = new McpTargetList();
185
+ list.add(null);
186
+ expect(list.toArray()).toEqual([]);
187
+ });
188
+
189
+ it("ignores empty string", () => {
190
+ const list = new McpTargetList();
191
+ list.add("");
192
+ expect(list.toArray()).toEqual([]);
193
+ });
194
+
195
+ it("appends a new value", () => {
196
+ const list = new McpTargetList();
197
+ list.add("exa");
198
+ expect(list.toArray()).toEqual(["exa"]);
199
+ });
200
+
201
+ it("dedups repeated values", () => {
202
+ const list = new McpTargetList();
203
+ list.add("exa");
204
+ list.add("exa");
205
+ expect(list.toArray()).toEqual(["exa"]);
206
+ });
207
+
208
+ it("preserves first-insertion order across a mix of values", () => {
209
+ const list = new McpTargetList();
210
+ list.add("exa_search");
211
+ list.add("exa:search");
212
+ list.add("exa");
213
+ list.add("exa_search"); // duplicate — must not change order
214
+ list.add("mcp_call");
215
+ expect(list.toArray()).toEqual([
216
+ "exa_search",
217
+ "exa:search",
218
+ "exa",
219
+ "mcp_call",
220
+ ]);
221
+ });
222
+ });
223
+
224
+ describe("toArray", () => {
225
+ it("returns an independent copy that does not mutate the list", () => {
226
+ const list = new McpTargetList();
227
+ list.add("exa");
228
+ const first = list.toArray();
229
+ first.push("mutated");
230
+ expect(list.toArray()).toEqual(["exa"]);
231
+ });
232
+ });
233
+ });
@@ -0,0 +1,295 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { afterEach, describe, expect, test, vi } from "vitest";
6
+
7
+ import {
8
+ PermissionForwarder,
9
+ type PermissionForwarderDeps,
10
+ } from "#src/forwarded-permissions/permission-forwarder";
11
+ import { createPermissionForwardingLocation } from "#src/permission-forwarding";
12
+
13
+ // ── Helpers ───────────────────────────────────────────────────────────────
14
+
15
+ function makeDeps(
16
+ overrides: Partial<PermissionForwarderDeps> = {},
17
+ ): PermissionForwarderDeps {
18
+ return {
19
+ forwardingDir: "/tmp/forwarding",
20
+ subagentSessionsDir: "/tmp/subagents",
21
+ logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
22
+ writeReviewLog: vi.fn(),
23
+ requestPermissionDecisionFromUi: vi
24
+ .fn()
25
+ .mockResolvedValue({ approved: true, state: "approved" as const }),
26
+ shouldAutoApprove: () => false,
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ afterEach(() => {
32
+ vi.unstubAllEnvs();
33
+ });
34
+
35
+ // ── requestApproval ───────────────────────────────────────────────────────
36
+
37
+ describe("requestApproval — UI fast path", () => {
38
+ test("calls requestPermissionDecisionFromUi but does not emit a UI prompt event (the prompter does)", async () => {
39
+ const events = {
40
+ emit: vi.fn(),
41
+ on: vi.fn().mockReturnValue(() => undefined),
42
+ };
43
+ const requestPermissionDecisionFromUi = vi
44
+ .fn()
45
+ .mockResolvedValue({ approved: true, state: "approved" as const });
46
+
47
+ const forwarder = new PermissionForwarder(
48
+ makeDeps({ events, requestPermissionDecisionFromUi }),
49
+ );
50
+
51
+ await forwarder.requestApproval(
52
+ {
53
+ hasUI: true,
54
+ ui: { select: vi.fn(), input: vi.fn() },
55
+ } as unknown as ExtensionContext,
56
+ "Allow git push?",
57
+ );
58
+
59
+ expect(requestPermissionDecisionFromUi).toHaveBeenCalled();
60
+ expect(events.emit).not.toHaveBeenCalledWith(
61
+ "permissions:ui_prompt",
62
+ expect.anything(),
63
+ );
64
+ });
65
+ });
66
+
67
+ describe("requestApproval — non-UI, non-subagent path", () => {
68
+ test("returns denied without showing a dialog or emitting when there is no active UI", async () => {
69
+ const events = {
70
+ emit: vi.fn(),
71
+ on: vi.fn().mockReturnValue(() => undefined),
72
+ };
73
+ const requestPermissionDecisionFromUi = vi.fn();
74
+
75
+ const forwarder = new PermissionForwarder(
76
+ makeDeps({ events, requestPermissionDecisionFromUi }),
77
+ );
78
+
79
+ const result = await forwarder.requestApproval(
80
+ {
81
+ hasUI: false,
82
+ sessionManager: {
83
+ getSessionDir: vi.fn().mockReturnValue(null),
84
+ },
85
+ } as unknown as ExtensionContext,
86
+ "Allow git push?",
87
+ );
88
+
89
+ expect(result).toEqual({ approved: false, state: "denied" });
90
+ expect(events.emit).not.toHaveBeenCalledWith(
91
+ "permissions:ui_prompt",
92
+ expect.anything(),
93
+ );
94
+ expect(requestPermissionDecisionFromUi).not.toHaveBeenCalled();
95
+ });
96
+ });
97
+
98
+ // ── processInbox ──────────────────────────────────────────────────────────
99
+
100
+ describe("processInbox", () => {
101
+ test("emits a UI prompt event before showing a forwarded permission dialog", async () => {
102
+ const root = mkdtempSync(join(tmpdir(), "permission-forwarding-"));
103
+ try {
104
+ const forwardingDir = join(root, "forwarding");
105
+ const location = createPermissionForwardingLocation(
106
+ forwardingDir,
107
+ "parent-session",
108
+ );
109
+ mkdirSync(location.requestsDir, { recursive: true });
110
+ mkdirSync(location.responsesDir, { recursive: true });
111
+ writeFileSync(
112
+ join(location.requestsDir, "req-forwarded.json"),
113
+ JSON.stringify({
114
+ id: "req-forwarded",
115
+ createdAt: Date.now(),
116
+ requesterSessionId: "child-session",
117
+ targetSessionId: "parent-session",
118
+ requesterAgentName: "Explore",
119
+ message: "Allow git push?",
120
+ }),
121
+ "utf-8",
122
+ );
123
+
124
+ const events = {
125
+ emit: vi.fn(),
126
+ on: vi.fn().mockReturnValue(() => undefined),
127
+ };
128
+ const requestPermissionDecisionFromUi = vi
129
+ .fn()
130
+ .mockResolvedValue({ approved: true, state: "approved" as const });
131
+
132
+ const forwarder = new PermissionForwarder(
133
+ makeDeps({
134
+ forwardingDir,
135
+ events,
136
+ requestPermissionDecisionFromUi,
137
+ }),
138
+ );
139
+
140
+ await forwarder.processInbox({
141
+ hasUI: true,
142
+ ui: { select: vi.fn(), input: vi.fn() },
143
+ sessionManager: {
144
+ getSessionId: vi.fn().mockReturnValue("parent-session"),
145
+ },
146
+ } as unknown as ExtensionContext);
147
+
148
+ expect(events.emit).toHaveBeenCalledWith(
149
+ "permissions:ui_prompt",
150
+ expect.objectContaining({
151
+ requestId: "req-forwarded",
152
+ source: "tool_call",
153
+ surface: null,
154
+ value: null,
155
+ agentName: "Explore",
156
+ message: expect.stringContaining("Allow git push?"),
157
+ forwarding: {
158
+ requesterAgentName: "Explore",
159
+ requesterSessionId: "child-session",
160
+ },
161
+ }),
162
+ );
163
+ expect(requestPermissionDecisionFromUi).toHaveBeenCalled();
164
+ } finally {
165
+ rmSync(root, { recursive: true, force: true });
166
+ }
167
+ });
168
+
169
+ test("emits a non-degraded UI prompt event when the request carries display fields", async () => {
170
+ const root = mkdtempSync(join(tmpdir(), "permission-forwarding-"));
171
+ try {
172
+ const forwardingDir = join(root, "forwarding");
173
+ const location = createPermissionForwardingLocation(
174
+ forwardingDir,
175
+ "parent-session",
176
+ );
177
+ mkdirSync(location.requestsDir, { recursive: true });
178
+ mkdirSync(location.responsesDir, { recursive: true });
179
+ writeFileSync(
180
+ join(location.requestsDir, "req-forwarded-rich.json"),
181
+ JSON.stringify({
182
+ id: "req-forwarded-rich",
183
+ createdAt: Date.now(),
184
+ requesterSessionId: "child-session",
185
+ targetSessionId: "parent-session",
186
+ requesterAgentName: "Explore",
187
+ message: "Allow git push?",
188
+ source: "tool_call",
189
+ surface: "bash",
190
+ value: "git push",
191
+ }),
192
+ "utf-8",
193
+ );
194
+
195
+ const events = {
196
+ emit: vi.fn(),
197
+ on: vi.fn().mockReturnValue(() => undefined),
198
+ };
199
+ const requestPermissionDecisionFromUi = vi
200
+ .fn()
201
+ .mockResolvedValue({ approved: true, state: "approved" as const });
202
+
203
+ const forwarder = new PermissionForwarder(
204
+ makeDeps({
205
+ forwardingDir,
206
+ events,
207
+ requestPermissionDecisionFromUi,
208
+ }),
209
+ );
210
+
211
+ await forwarder.processInbox({
212
+ hasUI: true,
213
+ ui: { select: vi.fn(), input: vi.fn() },
214
+ sessionManager: {
215
+ getSessionId: vi.fn().mockReturnValue("parent-session"),
216
+ },
217
+ } as unknown as ExtensionContext);
218
+
219
+ expect(events.emit).toHaveBeenCalledWith(
220
+ "permissions:ui_prompt",
221
+ expect.objectContaining({
222
+ requestId: "req-forwarded-rich",
223
+ source: "tool_call",
224
+ surface: "bash",
225
+ value: "git push",
226
+ agentName: "Explore",
227
+ message: expect.stringContaining("Allow git push?"),
228
+ forwarding: {
229
+ requesterAgentName: "Explore",
230
+ requesterSessionId: "child-session",
231
+ },
232
+ }),
233
+ );
234
+ expect(requestPermissionDecisionFromUi).toHaveBeenCalled();
235
+ } finally {
236
+ rmSync(root, { recursive: true, force: true });
237
+ }
238
+ });
239
+
240
+ test("does not emit a UI prompt event when forwarded permission auto-approves", async () => {
241
+ const root = mkdtempSync(join(tmpdir(), "permission-forwarding-"));
242
+ try {
243
+ const forwardingDir = join(root, "forwarding");
244
+ const location = createPermissionForwardingLocation(
245
+ forwardingDir,
246
+ "parent-session",
247
+ );
248
+ mkdirSync(location.requestsDir, { recursive: true });
249
+ mkdirSync(location.responsesDir, { recursive: true });
250
+ writeFileSync(
251
+ join(location.requestsDir, "req-forwarded-auto.json"),
252
+ JSON.stringify({
253
+ id: "req-forwarded-auto",
254
+ createdAt: Date.now(),
255
+ requesterSessionId: "child-session",
256
+ targetSessionId: "parent-session",
257
+ requesterAgentName: "Explore",
258
+ message: "Allow git push?",
259
+ }),
260
+ "utf-8",
261
+ );
262
+
263
+ const events = {
264
+ emit: vi.fn(),
265
+ on: vi.fn().mockReturnValue(() => undefined),
266
+ };
267
+ const requestPermissionDecisionFromUi = vi.fn();
268
+
269
+ const forwarder = new PermissionForwarder(
270
+ makeDeps({
271
+ forwardingDir,
272
+ events,
273
+ requestPermissionDecisionFromUi,
274
+ shouldAutoApprove: () => true,
275
+ }),
276
+ );
277
+
278
+ await forwarder.processInbox({
279
+ hasUI: true,
280
+ ui: { select: vi.fn(), input: vi.fn() },
281
+ sessionManager: {
282
+ getSessionId: vi.fn().mockReturnValue("parent-session"),
283
+ },
284
+ } as unknown as ExtensionContext);
285
+
286
+ expect(events.emit).not.toHaveBeenCalledWith(
287
+ "permissions:ui_prompt",
288
+ expect.anything(),
289
+ );
290
+ expect(requestPermissionDecisionFromUi).not.toHaveBeenCalled();
291
+ } finally {
292
+ rmSync(root, { recursive: true, force: true });
293
+ }
294
+ });
295
+ });