@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
@@ -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
+ });
@@ -1,17 +1,10 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
- // ── Module mocks ────────────────────────────────────────────────────────────
3
+ // ── Injected mock ───────────────────────────────────────────────────────────
4
4
 
5
- const { mockConfirmPermission } = vi.hoisted(() => ({
6
- mockConfirmPermission: vi.fn(),
7
- }));
5
+ const mockRequestApproval = vi.fn();
8
6
 
9
- vi.mock("../src/forwarded-permissions/polling", () => ({
10
- confirmPermission: mockConfirmPermission,
11
- processForwardedPermissionRequests: vi.fn().mockResolvedValue(undefined),
12
- }));
13
-
14
- // ── Imports (after mocks) ───────────────────────────────────────────────────
7
+ // ── Imports ─────────────────────────────────────────────────────────────────
15
8
 
16
9
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
17
10
  import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
@@ -51,11 +44,8 @@ function makeDeps(
51
44
  return {
52
45
  getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: false }),
53
46
  writeReviewLog: vi.fn(),
54
- subagentSessionsDir: "/sessions/subagents",
55
- forwardingDir: "/sessions/permission-forwarding",
56
- requestPermissionDecisionFromUi: vi
57
- .fn()
58
- .mockResolvedValue({ approved: true, state: "approved" }),
47
+ events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
48
+ forwarder: { requestApproval: mockRequestApproval },
59
49
  ...overrides,
60
50
  };
61
51
  }
@@ -64,15 +54,24 @@ function makeDeps(
64
54
 
65
55
  describe("PermissionPrompter", () => {
66
56
  beforeEach(() => {
67
- mockConfirmPermission.mockReset();
57
+ mockRequestApproval.mockReset();
58
+ mockRequestApproval.mockResolvedValue({
59
+ approved: true,
60
+ state: "approved",
61
+ });
68
62
  });
69
63
 
70
64
  // ── Yolo-mode auto-approve ───────────────────────────────────────────────
71
65
 
72
66
  describe("yolo-mode auto-approve", () => {
73
67
  it("returns approved without calling confirmPermission when yoloMode is true", async () => {
68
+ const events = {
69
+ emit: vi.fn(),
70
+ on: vi.fn().mockReturnValue(() => undefined),
71
+ };
74
72
  const deps = makeDeps({
75
73
  getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
74
+ events,
76
75
  });
77
76
  const prompter = new PermissionPrompter(deps);
78
77
 
@@ -83,7 +82,11 @@ describe("PermissionPrompter", () => {
83
82
  state: "approved",
84
83
  autoApproved: true,
85
84
  });
86
- expect(mockConfirmPermission).not.toHaveBeenCalled();
85
+ expect(mockRequestApproval).not.toHaveBeenCalled();
86
+ expect(events.emit).not.toHaveBeenCalledWith(
87
+ "permissions:ui_prompt",
88
+ expect.anything(),
89
+ );
87
90
  });
88
91
 
89
92
  it("logs permission_request.auto_approved in yolo mode", async () => {
@@ -126,7 +129,7 @@ describe("PermissionPrompter", () => {
126
129
 
127
130
  await prompter.prompt(makeCtx(true), makeDetails());
128
131
 
129
- expect(mockConfirmPermission).not.toHaveBeenCalled();
132
+ expect(mockRequestApproval).not.toHaveBeenCalled();
130
133
  });
131
134
  });
132
135
 
@@ -139,7 +142,7 @@ describe("PermissionPrompter", () => {
139
142
  approved: true,
140
143
  state: "approved",
141
144
  };
142
- mockConfirmPermission.mockResolvedValue(approved);
145
+ mockRequestApproval.mockResolvedValue(approved);
143
146
  const deps = makeDeps({ writeReviewLog });
144
147
  const prompter = new PermissionPrompter(deps);
145
148
 
@@ -154,9 +157,93 @@ describe("PermissionPrompter", () => {
154
157
  );
155
158
  });
156
159
 
160
+ it("emits a UI prompt event with normalized surface and value when the session has UI", async () => {
161
+ const events = {
162
+ emit: vi.fn(),
163
+ on: vi.fn().mockReturnValue(() => undefined),
164
+ };
165
+ mockRequestApproval.mockResolvedValue({
166
+ approved: true,
167
+ state: "approved",
168
+ });
169
+ const deps = makeDeps({ events });
170
+ const prompter = new PermissionPrompter(deps);
171
+
172
+ await prompter.prompt(
173
+ makeCtx(true),
174
+ makeDetails({
175
+ toolName: "bash",
176
+ command: "git push",
177
+ toolInputPreview: "git push",
178
+ }),
179
+ );
180
+
181
+ expect(events.emit).toHaveBeenCalledWith("permissions:ui_prompt", {
182
+ requestId: "req-123",
183
+ source: "tool_call",
184
+ surface: "bash",
185
+ value: "git push",
186
+ agentName: "test-agent",
187
+ message: "Allow read?",
188
+ forwarding: null,
189
+ });
190
+ });
191
+
192
+ it("normalizes skill UI prompt events to the skill surface", async () => {
193
+ const events = {
194
+ emit: vi.fn(),
195
+ on: vi.fn().mockReturnValue(() => undefined),
196
+ };
197
+ mockRequestApproval.mockResolvedValue({
198
+ approved: true,
199
+ state: "approved",
200
+ });
201
+ const deps = makeDeps({ events });
202
+ const prompter = new PermissionPrompter(deps);
203
+
204
+ await prompter.prompt(
205
+ makeCtx(true),
206
+ makeDetails({
207
+ source: "skill_input",
208
+ toolName: undefined,
209
+ skillName: "deploy-helper",
210
+ }),
211
+ );
212
+
213
+ expect(events.emit).toHaveBeenCalledWith("permissions:ui_prompt", {
214
+ requestId: "req-123",
215
+ source: "skill_input",
216
+ surface: "skill",
217
+ value: "deploy-helper",
218
+ agentName: "test-agent",
219
+ message: "Allow read?",
220
+ forwarding: null,
221
+ });
222
+ });
223
+
224
+ it("does not emit a UI prompt event when the session has no UI", async () => {
225
+ const events = {
226
+ emit: vi.fn(),
227
+ on: vi.fn().mockReturnValue(() => undefined),
228
+ };
229
+ mockRequestApproval.mockResolvedValue({
230
+ approved: true,
231
+ state: "approved",
232
+ });
233
+ const deps = makeDeps({ events });
234
+ const prompter = new PermissionPrompter(deps);
235
+
236
+ await prompter.prompt(makeCtx(false), makeDetails());
237
+
238
+ expect(events.emit).not.toHaveBeenCalledWith(
239
+ "permissions:ui_prompt",
240
+ expect.anything(),
241
+ );
242
+ });
243
+
157
244
  it("logs permission_request.approved when confirmPermission returns approved", async () => {
158
245
  const writeReviewLog = vi.fn();
159
- mockConfirmPermission.mockResolvedValue({
246
+ mockRequestApproval.mockResolvedValue({
160
247
  approved: true,
161
248
  state: "approved",
162
249
  });
@@ -176,7 +263,7 @@ describe("PermissionPrompter", () => {
176
263
 
177
264
  it("logs permission_request.denied when confirmPermission returns denied", async () => {
178
265
  const writeReviewLog = vi.fn();
179
- mockConfirmPermission.mockResolvedValue({
266
+ mockRequestApproval.mockResolvedValue({
180
267
  approved: false,
181
268
  state: "denied",
182
269
  });
@@ -196,7 +283,7 @@ describe("PermissionPrompter", () => {
196
283
 
197
284
  it("logs permission_request.denied with denialReason when present", async () => {
198
285
  const writeReviewLog = vi.fn();
199
- mockConfirmPermission.mockResolvedValue({
286
+ mockRequestApproval.mockResolvedValue({
200
287
  approved: false,
201
288
  state: "denied_with_reason",
202
289
  denialReason: "too sensitive",
@@ -220,7 +307,7 @@ describe("PermissionPrompter", () => {
220
307
  state: "denied_with_reason",
221
308
  denialReason: "sensitive",
222
309
  };
223
- mockConfirmPermission.mockResolvedValue(decision);
310
+ mockRequestApproval.mockResolvedValue(decision);
224
311
  const deps = makeDeps();
225
312
  const prompter = new PermissionPrompter(deps);
226
313
 
@@ -230,7 +317,7 @@ describe("PermissionPrompter", () => {
230
317
  });
231
318
 
232
319
  it("passes sessionLabel option to confirmPermission when present", async () => {
233
- mockConfirmPermission.mockResolvedValue({
320
+ mockRequestApproval.mockResolvedValue({
234
321
  approved: true,
235
322
  state: "approved",
236
323
  });
@@ -240,16 +327,38 @@ describe("PermissionPrompter", () => {
240
327
 
241
328
  await prompter.prompt(makeCtx(true), details);
242
329
 
243
- expect(mockConfirmPermission).toHaveBeenCalledWith(
330
+ expect(mockRequestApproval).toHaveBeenCalledWith(
244
331
  expect.anything(),
245
332
  expect.any(String),
246
- expect.anything(),
247
333
  { sessionLabel: "Yes, for 'read' tool" },
334
+ { source: "tool_call", surface: "read", value: "read" },
335
+ );
336
+ });
337
+
338
+ it("passes the display fields (source/surface/value) to confirmPermission", async () => {
339
+ mockRequestApproval.mockResolvedValue({
340
+ approved: true,
341
+ state: "approved",
342
+ });
343
+ const deps = makeDeps();
344
+ const prompter = new PermissionPrompter(deps);
345
+ const details = makeDetails({
346
+ toolName: "bash",
347
+ command: "git push",
348
+ });
349
+
350
+ await prompter.prompt(makeCtx(false), details);
351
+
352
+ expect(mockRequestApproval).toHaveBeenCalledWith(
353
+ expect.anything(),
354
+ expect.any(String),
355
+ undefined,
356
+ { source: "tool_call", surface: "bash", value: "git push" },
248
357
  );
249
358
  });
250
359
 
251
360
  it("passes undefined options to confirmPermission when sessionLabel is absent", async () => {
252
- mockConfirmPermission.mockResolvedValue({
361
+ mockRequestApproval.mockResolvedValue({
253
362
  approved: true,
254
363
  state: "approved",
255
364
  });
@@ -258,16 +367,16 @@ describe("PermissionPrompter", () => {
258
367
 
259
368
  await prompter.prompt(makeCtx(true), makeDetails());
260
369
 
261
- expect(mockConfirmPermission).toHaveBeenCalledWith(
370
+ expect(mockRequestApproval).toHaveBeenCalledWith(
262
371
  expect.anything(),
263
372
  expect.any(String),
264
- expect.anything(),
265
373
  undefined,
374
+ { source: "tool_call", surface: "read", value: "read" },
266
375
  );
267
376
  });
268
377
 
269
378
  it("passes the message from details to confirmPermission", async () => {
270
- mockConfirmPermission.mockResolvedValue({
379
+ mockRequestApproval.mockResolvedValue({
271
380
  approved: true,
272
381
  state: "approved",
273
382
  });
@@ -277,11 +386,11 @@ describe("PermissionPrompter", () => {
277
386
 
278
387
  await prompter.prompt(makeCtx(true), details);
279
388
 
280
- expect(mockConfirmPermission).toHaveBeenCalledWith(
389
+ expect(mockRequestApproval).toHaveBeenCalledWith(
281
390
  expect.anything(),
282
391
  "Allow bash: git status?",
283
- expect.anything(),
284
392
  undefined,
393
+ { source: "tool_call", surface: "read", value: "read" },
285
394
  );
286
395
  });
287
396
  });
@@ -291,7 +400,7 @@ describe("PermissionPrompter", () => {
291
400
  describe("review log fields", () => {
292
401
  it("includes all standard fields in the waiting log entry", async () => {
293
402
  const writeReviewLog = vi.fn();
294
- mockConfirmPermission.mockResolvedValue({
403
+ mockRequestApproval.mockResolvedValue({
295
404
  approved: true,
296
405
  state: "approved",
297
406
  });
@@ -328,7 +437,7 @@ describe("PermissionPrompter", () => {
328
437
 
329
438
  it("uses null for optional fields not present in details", async () => {
330
439
  const writeReviewLog = vi.fn();
331
- mockConfirmPermission.mockResolvedValue({
440
+ mockRequestApproval.mockResolvedValue({
332
441
  approved: true,
333
442
  state: "approved",
334
443
  });
@@ -359,13 +468,13 @@ describe("PermissionPrompter", () => {
359
468
  approved: true,
360
469
  state: "approved",
361
470
  };
362
- mockConfirmPermission.mockResolvedValue(forwarded);
471
+ mockRequestApproval.mockResolvedValue(forwarded);
363
472
  const deps = makeDeps();
364
473
  const prompter = new PermissionPrompter(deps);
365
474
 
366
475
  await prompter.prompt(makeCtx(false), makeDetails());
367
476
 
368
- expect(mockConfirmPermission).toHaveBeenCalled();
477
+ expect(mockRequestApproval).toHaveBeenCalled();
369
478
  });
370
479
 
371
480
  it("returns the decision from confirmPermission in the subagent path", async () => {
@@ -373,7 +482,7 @@ describe("PermissionPrompter", () => {
373
482
  approved: false,
374
483
  state: "denied",
375
484
  };
376
- mockConfirmPermission.mockResolvedValue(forwarded);
485
+ mockRequestApproval.mockResolvedValue(forwarded);
377
486
  const deps = makeDeps();
378
487
  const prompter = new PermissionPrompter(deps);
379
488
 
@@ -384,7 +493,7 @@ describe("PermissionPrompter", () => {
384
493
 
385
494
  it("logs the outcome when confirmPermission resolves via forwarding", async () => {
386
495
  const writeReviewLog = vi.fn();
387
- mockConfirmPermission.mockResolvedValue({
496
+ mockRequestApproval.mockResolvedValue({
388
497
  approved: true,
389
498
  state: "approved",
390
499
  });