@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
@@ -3,15 +3,35 @@
3
3
  */
4
4
  import { vi } from "vitest";
5
5
 
6
- import type {
7
- GateDescriptor,
8
- GateRunnerDeps,
9
- } from "#src/handlers/gates/descriptor";
6
+ import type { DecisionReporter } from "#src/decision-reporter";
7
+ import type { GatePrompter } from "#src/gate-prompter";
8
+ import type { GateDescriptor } from "#src/handlers/gates/descriptor";
9
+ import { GateRunner } from "#src/handlers/gates/runner";
10
+ import type { SkillInputGateInputs } from "#src/handlers/gates/skill-input-gate-pipeline";
11
+ import type { ToolCallGateInputs } from "#src/handlers/gates/tool-call-gate-pipeline";
10
12
  import type { ToolCallContext } from "#src/handlers/gates/types";
13
+ import type { PermissionResolver } from "#src/permission-resolver";
14
+ import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
15
+ import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
16
+ import type { ToolPreviewFormatterOptions } from "#src/tool-preview-formatter";
11
17
  import type { PermissionCheckResult } from "#src/types";
12
18
 
13
19
  import { makeCheckResult } from "#test/helpers/handler-fixtures";
14
20
 
21
+ /**
22
+ * Permission resolver mock with an optional default check result.
23
+ *
24
+ * Returns a plain object whose `resolve` is a `vi.fn` so callers retain full
25
+ * mock access (`mockReturnValue`, `mockImplementation`, `mock.calls`).
26
+ */
27
+ export function makeResolver(defaultCheck?: PermissionCheckResult) {
28
+ const resolve = vi.fn<PermissionResolver["resolve"]>();
29
+ if (defaultCheck) {
30
+ resolve.mockReturnValue(defaultCheck);
31
+ }
32
+ return { resolve };
33
+ }
34
+
15
35
  /**
16
36
  * Gate descriptor factory with runner-test defaults.
17
37
  *
@@ -48,25 +68,70 @@ export function makeDescriptor(
48
68
  };
49
69
  }
50
70
 
51
- export function makeRunnerDeps(
52
- overrides: Partial<GateRunnerDeps> = {},
53
- ): GateRunnerDeps {
71
+ /**
72
+ * Reporter mock with independently inspectable vi.fn() stubs.
73
+ */
74
+ export function makeReporter(
75
+ overrides: Partial<DecisionReporter> = {},
76
+ ): DecisionReporter {
54
77
  return {
55
- checkPermission: vi
56
- .fn()
57
- .mockReturnValue(makeCheckResult({ matchedPattern: "*" })),
58
- getSessionRuleset: vi.fn().mockReturnValue([]),
59
- recordSessionApproval: vi.fn(),
60
78
  writeReviewLog: vi.fn(),
61
79
  emitDecision: vi.fn(),
62
- canConfirm: vi.fn().mockReturnValue(true),
63
- promptPermission: vi
64
- .fn()
65
- .mockResolvedValue({ approved: true, state: "approved" }),
66
80
  ...overrides,
67
81
  };
68
82
  }
69
83
 
84
+ /**
85
+ * Gate runner factory for `GateRunner` unit tests.
86
+ *
87
+ * Builds one `GateRunner` from four role mocks and returns `{ runner, deps }`
88
+ * so tests can both invoke `runner.run(...)` and assert on the individual
89
+ * mock call records (`deps.reporter.*`, `deps.resolve`, etc.).
90
+ */
91
+ export function makeGateRunner(
92
+ overrides: {
93
+ resolve?: PermissionResolver["resolve"];
94
+ recordSessionApproval?: SessionApprovalRecorder["recordSessionApproval"];
95
+ canConfirm?: GatePrompter["canConfirm"];
96
+ promptPermission?: GatePrompter["promptPermission"];
97
+ reporter?: Partial<DecisionReporter>;
98
+ } = {},
99
+ ) {
100
+ const reporter = makeReporter(overrides.reporter);
101
+ const resolve =
102
+ overrides.resolve ??
103
+ vi
104
+ .fn<PermissionResolver["resolve"]>()
105
+ .mockReturnValue(makeCheckResult({ matchedPattern: "*" }));
106
+ const recordSessionApproval =
107
+ overrides.recordSessionApproval ??
108
+ (vi.fn() as SessionApprovalRecorder["recordSessionApproval"]);
109
+ const canConfirm =
110
+ overrides.canConfirm ??
111
+ (vi.fn().mockReturnValue(true) as GatePrompter["canConfirm"]);
112
+ const promptPermission =
113
+ overrides.promptPermission ??
114
+ vi
115
+ .fn<GatePrompter["promptPermission"]>()
116
+ .mockResolvedValue({ approved: true, state: "approved" });
117
+ const runner = new GateRunner(
118
+ { resolve },
119
+ { recordSessionApproval },
120
+ { canConfirm, promptPermission },
121
+ reporter,
122
+ );
123
+ return {
124
+ runner,
125
+ deps: {
126
+ resolve,
127
+ recordSessionApproval,
128
+ canConfirm,
129
+ promptPermission,
130
+ reporter,
131
+ },
132
+ };
133
+ }
134
+
70
135
  /**
71
136
  * Tool-call context factory with bash defaults.
72
137
  *
@@ -103,3 +168,69 @@ export function makeGateCheckResult(
103
168
  ...overrides,
104
169
  };
105
170
  }
171
+
172
+ /**
173
+ * Mock of `ToolCallGateInputs` for `ToolCallGatePipeline` unit tests.
174
+ *
175
+ * Each method is a `vi.fn()` stub so callers retain full mock access
176
+ * (`mock.calls`, `mockReturnValue`, etc.) on the returned object.
177
+ * Pass `overrides` to replace individual stubs without rebuilding the whole
178
+ * mock from scratch.
179
+ */
180
+ export function makeGateInputs(
181
+ overrides: {
182
+ resolve?: PermissionResolver["resolve"];
183
+ getActiveSkillEntries?: () => SkillPromptEntry[];
184
+ getInfrastructureReadDirs?: () => string[];
185
+ getToolPreviewLimits?: () => ToolPreviewFormatterOptions;
186
+ } = {},
187
+ ): ToolCallGateInputs {
188
+ return {
189
+ resolve:
190
+ overrides.resolve ??
191
+ vi.fn<PermissionResolver["resolve"]>().mockReturnValue(makeCheckResult()),
192
+ getActiveSkillEntries:
193
+ overrides.getActiveSkillEntries ??
194
+ vi.fn<() => SkillPromptEntry[]>(() => []),
195
+ getInfrastructureReadDirs:
196
+ overrides.getInfrastructureReadDirs ?? vi.fn<() => string[]>(() => []),
197
+ getToolPreviewLimits:
198
+ overrides.getToolPreviewLimits ??
199
+ vi.fn<() => ToolPreviewFormatterOptions>(() => ({
200
+ toolInputPreviewMaxLength: 500,
201
+ toolTextSummaryMaxLength: 100,
202
+ toolInputLogPreviewMaxLength: 200,
203
+ })),
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Mock of `SkillInputGateInputs` for `SkillInputGatePipeline` unit tests.
209
+ *
210
+ * Returns a plain object with a `checkPermission` `vi.fn()` stub so callers
211
+ * retain full mock access (`mockReturnValue`, `mock.calls`, etc.).
212
+ */
213
+ export function makeSkillInputInputs(
214
+ overrides: { checkPermission?: SkillInputGateInputs["checkPermission"] } = {},
215
+ ): SkillInputGateInputs {
216
+ return {
217
+ checkPermission:
218
+ overrides.checkPermission ??
219
+ vi
220
+ .fn<SkillInputGateInputs["checkPermission"]>()
221
+ .mockReturnValue(makeCheckResult()),
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Mock `GateNotifier` for `SkillInputGatePipeline` unit tests.
227
+ *
228
+ * Return type is intentionally unannotated so callers retain full `vi.fn()`
229
+ * mock access (`mock.calls`, `toHaveBeenCalledWith`, etc.) — annotating with
230
+ * `GateNotifier` would erase `Mock<...>` methods from the inferred type.
231
+ */
232
+ export function makeNotifier() {
233
+ return {
234
+ warn: vi.fn<(message: string) => void>(),
235
+ };
236
+ }
@@ -7,14 +7,67 @@
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
33
  import type { PermissionCheckResult } from "#src/types";
17
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
+ };
70
+
18
71
  export function makeEvents() {
19
72
  return {
20
73
  emit: vi.fn(),
@@ -75,33 +128,86 @@ export function makeCheckResult(
75
128
  }
76
129
 
77
130
  /**
78
- * Full-union session stub.
131
+ * Full-intersection session stub.
79
132
  *
80
- * Includes every method mocked across handler test files so each file
81
- * only needs to override the fields that differ from the defaults.
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.
136
+ *
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(
@@ -121,13 +227,23 @@ export function makeToolRegistry(
121
227
  * it needs — handler, events, session, and toolRegistry are all available.
122
228
  */
123
229
  export function makeHandler(overrides?: {
124
- session?: Partial<Record<keyof PermissionSession, unknown>>;
230
+ session?: Partial<MockGateHandlerSession>;
125
231
  toolRegistry?: Partial<ToolRegistry>;
126
232
  }) {
127
233
  const session = makeSession(overrides?.session);
128
234
  const events = makeEvents();
129
235
  const toolRegistry = makeToolRegistry(overrides?.toolRegistry);
130
- const handler = new PermissionGateHandler(session, events, toolRegistry);
236
+ const pipeline = new ToolCallGatePipeline(session);
237
+ const skillInputPipeline = new SkillInputGatePipeline(session);
238
+ const reporter = new GateDecisionReporter(session.logger, events);
239
+ const runner = new GateRunner(session, session, session, reporter);
240
+ const handler = new PermissionGateHandler(
241
+ session,
242
+ toolRegistry,
243
+ pipeline,
244
+ skillInputPipeline,
245
+ runner,
246
+ );
131
247
  return { handler, events, session, toolRegistry };
132
248
  }
133
249
 
@@ -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
+ });
@@ -13,6 +13,7 @@ import {
13
13
  PERMISSIONS_PROTOCOL_VERSION,
14
14
  PERMISSIONS_RPC_CHECK_CHANNEL,
15
15
  PERMISSIONS_RPC_PROMPT_CHANNEL,
16
+ PERMISSIONS_UI_PROMPT_CHANNEL,
16
17
  } from "#src/permission-events";
17
18
 
18
19
  // ── Helpers ────────────────────────────────────────────────────────────────
@@ -317,6 +318,44 @@ describe("registerPermissionRpcHandlers — permissions:rpc:prompt", () => {
317
318
  }
318
319
  });
319
320
 
321
+ it("emits a UI prompt broadcast before awaiting the UI decision", async () => {
322
+ const bus = createEventBus();
323
+ const ctx = makeCtxWithUi();
324
+ const requestUi = vi
325
+ .fn()
326
+ .mockResolvedValue({ approved: true, state: "approved" as const });
327
+ const deps = makeDeps({
328
+ getRuntimeContext: vi.fn().mockReturnValue(ctx),
329
+ requestPermissionDecisionFromUi: requestUi,
330
+ });
331
+ registerPermissionRpcHandlers(bus, deps);
332
+
333
+ const promptPromise = waitForReply(bus, PERMISSIONS_UI_PROMPT_CHANNEL);
334
+ const replyPromise = waitForReply(
335
+ bus,
336
+ `${PERMISSIONS_RPC_PROMPT_CHANNEL}:reply:req-prompt-broadcast`,
337
+ );
338
+ bus.emit(PERMISSIONS_RPC_PROMPT_CHANNEL, {
339
+ requestId: "req-prompt-broadcast",
340
+ surface: "bash",
341
+ value: "git push",
342
+ message: "Allow git push?",
343
+ agentName: "Worker",
344
+ sessionLabel: "Allow git *",
345
+ });
346
+
347
+ await expect(promptPromise).resolves.toEqual({
348
+ requestId: "req-prompt-broadcast",
349
+ source: "rpc_prompt",
350
+ surface: "bash",
351
+ value: "git push",
352
+ agentName: "Worker",
353
+ message: "Allow git push?",
354
+ forwarding: null,
355
+ });
356
+ await replyPromise;
357
+ });
358
+
320
359
  it("passes the message to requestPermissionDecisionFromUi", async () => {
321
360
  const bus = createEventBus();
322
361
  const ctx = makeCtxWithUi();
@@ -14,15 +14,18 @@ import type {
14
14
  PermissionsPromptRequest,
15
15
  PermissionsReadyEvent,
16
16
  PermissionsRpcReply,
17
+ PermissionUiPromptEvent,
17
18
  } from "#src/permission-events";
18
19
  import {
19
20
  emitDecisionEvent,
20
21
  emitReadyEvent,
22
+ emitUiPromptEvent,
21
23
  PERMISSIONS_DECISION_CHANNEL,
22
24
  PERMISSIONS_PROTOCOL_VERSION,
23
25
  PERMISSIONS_READY_CHANNEL,
24
26
  PERMISSIONS_RPC_CHECK_CHANNEL,
25
27
  PERMISSIONS_RPC_PROMPT_CHANNEL,
28
+ PERMISSIONS_UI_PROMPT_CHANNEL,
26
29
  } from "#src/permission-events";
27
30
 
28
31
  // ── Minimal EventBus stub ──────────────────────────────────────────────────
@@ -43,6 +46,7 @@ describe("constants", () => {
43
46
 
44
47
  it("channel names have the correct values", () => {
45
48
  expect(PERMISSIONS_READY_CHANNEL).toBe("permissions:ready");
49
+ expect(PERMISSIONS_UI_PROMPT_CHANNEL).toBe("permissions:ui_prompt");
46
50
  expect(PERMISSIONS_DECISION_CHANNEL).toBe("permissions:decision");
47
51
  expect(PERMISSIONS_RPC_CHECK_CHANNEL).toBe("permissions:rpc:check");
48
52
  expect(PERMISSIONS_RPC_PROMPT_CHANNEL).toBe("permissions:rpc:prompt");
@@ -52,20 +56,75 @@ describe("constants", () => {
52
56
  // ── emitReadyEvent ─────────────────────────────────────────────────────────
53
57
 
54
58
  describe("emitReadyEvent", () => {
55
- it("emits on the permissions:ready channel with protocol version", () => {
59
+ it("emits an empty payload on the permissions:ready channel", () => {
56
60
  const bus = makeEventBus();
57
61
  emitReadyEvent(bus);
58
62
  expect(bus.emit).toHaveBeenCalledOnce();
59
- expect(bus.emit).toHaveBeenCalledWith("permissions:ready", {
60
- protocolVersion: 1,
61
- });
63
+ expect(bus.emit).toHaveBeenCalledWith("permissions:ready", {});
62
64
  });
63
65
 
64
- it("emitted payload satisfies PermissionsReadyEvent shape", () => {
66
+ it("carries no protocolVersion (version lives in the RPC envelope)", () => {
65
67
  const bus = makeEventBus();
66
68
  emitReadyEvent(bus);
67
69
  const payload = bus.emit.mock.calls[0][1] as PermissionsReadyEvent;
68
- expect(typeof payload.protocolVersion).toBe("number");
70
+ expect(payload).not.toHaveProperty("protocolVersion");
71
+ });
72
+
73
+ it("swallows event bus errors because broadcasts are best-effort", () => {
74
+ const bus = {
75
+ emit: vi.fn(() => {
76
+ throw new Error("listener failed");
77
+ }),
78
+ on: vi.fn().mockReturnValue(() => undefined),
79
+ };
80
+
81
+ expect(() => emitReadyEvent(bus)).not.toThrow();
82
+ });
83
+ });
84
+
85
+ // ── emitUiPromptEvent ──────────────────────────────────────────────────────
86
+
87
+ describe("emitUiPromptEvent", () => {
88
+ function makeUiPromptEvent(
89
+ overrides: Partial<PermissionUiPromptEvent> = {},
90
+ ): PermissionUiPromptEvent {
91
+ return {
92
+ requestId: "req-123",
93
+ source: "tool_call",
94
+ surface: "bash",
95
+ value: "git status",
96
+ agentName: "Explore",
97
+ message: "Allow git status?",
98
+ forwarding: null,
99
+ ...overrides,
100
+ };
101
+ }
102
+
103
+ it("emits on the permissions:ui_prompt channel", () => {
104
+ const bus = makeEventBus();
105
+ emitUiPromptEvent(bus, makeUiPromptEvent());
106
+ expect(bus.emit).toHaveBeenCalledOnce();
107
+ expect(bus.emit.mock.calls[0][0]).toBe("permissions:ui_prompt");
108
+ });
109
+
110
+ it("forwards the full payload unchanged", () => {
111
+ const bus = makeEventBus();
112
+ const event = makeUiPromptEvent({
113
+ forwarding: { requesterAgentName: "Worker", requesterSessionId: "child" },
114
+ });
115
+ emitUiPromptEvent(bus, event);
116
+ expect(bus.emit.mock.calls[0][1]).toEqual(event);
117
+ });
118
+
119
+ it("swallows event bus errors because UI prompt broadcasts are observational", () => {
120
+ const bus = {
121
+ emit: vi.fn(() => {
122
+ throw new Error("listener failed");
123
+ }),
124
+ on: vi.fn().mockReturnValue(() => undefined),
125
+ };
126
+
127
+ expect(() => emitUiPromptEvent(bus, makeUiPromptEvent())).not.toThrow();
69
128
  });
70
129
  });
71
130
 
@@ -143,6 +202,17 @@ describe("emitDecisionEvent", () => {
143
202
  expect(payload.agentName).toBeNull();
144
203
  expect(payload.matchedPattern).toBeNull();
145
204
  });
205
+
206
+ it("swallows event bus errors because broadcasts are best-effort", () => {
207
+ const bus = {
208
+ emit: vi.fn(() => {
209
+ throw new Error("listener failed");
210
+ }),
211
+ on: vi.fn().mockReturnValue(() => undefined),
212
+ };
213
+
214
+ expect(() => emitDecisionEvent(bus, makeDecisionEvent())).not.toThrow();
215
+ });
146
216
  });
147
217
 
148
218
  // ── Type-shape compile-time checks (runtime assertions on literal values) ──
@@ -279,7 +349,7 @@ describe("piPermissionSystemExtension ready event wiring", () => {
279
349
  rmSync(baseDir, { recursive: true, force: true });
280
350
  });
281
351
 
282
- it("emits permissions:ready with protocolVersion at session_start", async () => {
352
+ it("emits permissions:ready at session_start", async () => {
283
353
  const emitSpy = vi.fn();
284
354
  const handlers = new Map<
285
355
  string,
@@ -324,8 +394,6 @@ describe("piPermissionSystemExtension ready event wiring", () => {
324
394
  ([channel]) => channel === PERMISSIONS_READY_CHANNEL,
325
395
  );
326
396
  expect(readyCalls).toHaveLength(1);
327
- expect(readyCalls[0][1]).toEqual({
328
- protocolVersion: PERMISSIONS_PROTOCOL_VERSION,
329
- });
397
+ expect(readyCalls[0][1]).toEqual({});
330
398
  });
331
399
  });