@gotgenes/pi-permission-system 4.3.0 → 4.4.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [4.4.0](https://github.com/gotgenes/pi-permission-system/compare/v4.3.0...v4.4.0) (2026-05-05)
9
+
10
+
11
+ ### Features
12
+
13
+ * wire PermissionPrompter and remove runtime promptPermission ([#80](https://github.com/gotgenes/pi-permission-system/issues/80)) ([8e0980a](https://github.com/gotgenes/pi-permission-system/commit/8e0980a207f9f665ad2af3ad635d73e28d313c91))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * add permission-prompter architecture note ([#80](https://github.com/gotgenes/pi-permission-system/issues/80)) ([6cc1b60](https://github.com/gotgenes/pi-permission-system/commit/6cc1b6089a332f262919d20ad27d5ca7a69b0b6d))
19
+ * add permission-prompter to target architecture module map ([#80](https://github.com/gotgenes/pi-permission-system/issues/80)) ([c5cf101](https://github.com/gotgenes/pi-permission-system/commit/c5cf101af827dc108c66c5dffff94e05d4234e4c))
20
+ * add permission-prompter to v3 architecture module map ([#80](https://github.com/gotgenes/pi-permission-system/issues/80)) ([94be5b5](https://github.com/gotgenes/pi-permission-system/commit/94be5b58b9018b1918102089bb2fff8401d8bad5))
21
+ * plan extract PermissionPrompter class ([#80](https://github.com/gotgenes/pi-permission-system/issues/80)) ([50fcf34](https://github.com/gotgenes/pi-permission-system/commit/50fcf3400ed8574b036cacd2afc1b9e2161d41c6))
22
+ * remove interim permission-prompter from target architecture map ([#80](https://github.com/gotgenes/pi-permission-system/issues/80)) ([f300f08](https://github.com/gotgenes/pi-permission-system/commit/f300f086b42d9d52ff0668b63a191addebe3140e))
23
+ * **retro:** add retro notes for issue [#51](https://github.com/gotgenes/pi-permission-system/issues/51) ([79a564d](https://github.com/gotgenes/pi-permission-system/commit/79a564d24977da7cffa3d9d65391a75dd0d7e99c))
24
+ * update target architecture for completed work and new issues ([#80](https://github.com/gotgenes/pi-permission-system/issues/80)) ([e661345](https://github.com/gotgenes/pi-permission-system/commit/e661345c8a699e702cbaa9adb7d61c80a25010d2))
25
+
8
26
  ## [4.3.0](https://github.com/gotgenes/pi-permission-system/compare/v4.2.0...v4.3.0) (2026-05-04)
9
27
 
10
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "4.3.0",
3
+ "version": "4.4.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
package/src/index.ts CHANGED
@@ -12,11 +12,11 @@ import {
12
12
  handleToolCall,
13
13
  } from "./handlers";
14
14
  import { requestPermissionDecisionFromUi } from "./permission-dialog";
15
+ import { PermissionPrompter } from "./permission-prompter";
15
16
  import {
16
17
  createExtensionRuntime,
17
18
  createPermissionManagerForCwd,
18
19
  logResolvedConfigPaths,
19
- promptPermission,
20
20
  refreshExtensionConfig,
21
21
  resolveAgentName,
22
22
  saveExtensionConfig,
@@ -32,6 +32,14 @@ import {
32
32
  export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
33
33
  const runtime = createExtensionRuntime();
34
34
 
35
+ const prompter = new PermissionPrompter({
36
+ getConfig: () => runtime.config,
37
+ writeReviewLog: runtime.writeReviewLog.bind(runtime),
38
+ subagentSessionsDir: runtime.subagentSessionsDir,
39
+ forwardingDir: runtime.forwardingDir,
40
+ requestPermissionDecisionFromUi,
41
+ });
42
+
35
43
  const forwardingDeps: PermissionForwardingDeps = {
36
44
  forwardingDir: runtime.forwardingDir,
37
45
  subagentSessionsDir: runtime.subagentSessionsDir,
@@ -74,8 +82,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
74
82
  runtime.subagentSessionsDir,
75
83
  ),
76
84
  }),
77
- promptPermission: (ctx, details) =>
78
- promptPermission(runtime, forwardingDeps, ctx, details),
85
+ promptPermission: (ctx, details) => prompter.prompt(ctx, details),
79
86
  createPermissionRequestId,
80
87
  startForwardedPermissionPolling: (ctx) =>
81
88
  startForwardedPermissionPolling(runtime, forwardingDeps, ctx),
@@ -0,0 +1,146 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import type { PermissionSystemExtensionConfig } from "./extension-config";
3
+ import type { ForwardedPermissionLogger } from "./forwarded-permissions/io";
4
+ import {
5
+ confirmPermission,
6
+ type PermissionForwardingDeps,
7
+ } from "./forwarded-permissions/polling";
8
+ import type { PromptPermissionDetails } from "./handlers/types";
9
+ import type {
10
+ PermissionPromptDecision,
11
+ RequestPermissionOptions,
12
+ } from "./permission-dialog";
13
+ import { shouldAutoApprovePermissionState } from "./yolo-mode";
14
+
15
+ /** Mockable contract exposed to handlers via HandlerDeps. */
16
+ export interface PermissionPrompterApi {
17
+ prompt(
18
+ ctx: ExtensionContext,
19
+ details: PromptPermissionDetails,
20
+ ): Promise<PermissionPromptDecision>;
21
+ }
22
+
23
+ /**
24
+ * Dependencies required by PermissionPrompter.
25
+ *
26
+ * Keeps the prompter's external surface narrow: callers provide config
27
+ * access, review-log writing, path constants, and the UI dialog function.
28
+ * The prompter synthesises the PermissionForwardingDeps it needs internally.
29
+ */
30
+ export interface PermissionPrompterDeps {
31
+ /** Read current config for yolo-mode check (called at prompt time). */
32
+ getConfig(): PermissionSystemExtensionConfig;
33
+ /** Write structured entries to the permission review log. */
34
+ writeReviewLog(event: string, details: Record<string, unknown>): void;
35
+ /** Directory containing subagent session state. */
36
+ subagentSessionsDir: string;
37
+ /** Directory used for file-based permission forwarding requests/responses. */
38
+ forwardingDir: string;
39
+ /** Show the interactive permission dialog in the UI. */
40
+ requestPermissionDecisionFromUi(
41
+ ui: ExtensionContext["ui"],
42
+ title: string,
43
+ message: string,
44
+ options?: RequestPermissionOptions,
45
+ ): Promise<PermissionPromptDecision>;
46
+ }
47
+
48
+ /**
49
+ * Encapsulates the full permission-prompt flow:
50
+ * 1. Yolo-mode auto-approval check.
51
+ * 2. Review-log "waiting" entry.
52
+ * 3. UI-present vs. subagent-forwarding branching (via confirmPermission).
53
+ * 4. Review-log "approved" / "denied" entry.
54
+ *
55
+ * Injecting a single PermissionPrompter instance into HandlerDeps means
56
+ * adding a new prompt parameter (e.g. a future sessionLabel variant) only
57
+ * requires changing PromptPermissionDetails and this class — not the full
58
+ * 4-file threading chain.
59
+ */
60
+ export class PermissionPrompter implements PermissionPrompterApi {
61
+ constructor(private readonly deps: PermissionPrompterDeps) {}
62
+
63
+ async prompt(
64
+ ctx: ExtensionContext,
65
+ details: PromptPermissionDetails,
66
+ ): Promise<PermissionPromptDecision> {
67
+ if (shouldAutoApprovePermissionState("ask", this.deps.getConfig())) {
68
+ this.writeReviewEntry("permission_request.auto_approved", details);
69
+ return { approved: true, state: "approved" };
70
+ }
71
+
72
+ this.writeReviewEntry("permission_request.waiting", details);
73
+
74
+ const decision = await confirmPermission(
75
+ ctx,
76
+ details.message,
77
+ this.buildForwardingDeps(),
78
+ details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
79
+ );
80
+
81
+ this.writeReviewEntry(
82
+ decision.approved
83
+ ? "permission_request.approved"
84
+ : "permission_request.denied",
85
+ {
86
+ ...details,
87
+ resolution: decision.state,
88
+ denialReason: decision.denialReason,
89
+ },
90
+ );
91
+
92
+ return decision;
93
+ }
94
+
95
+ // ── Private helpers ──────────────────────────────────────────────────────
96
+
97
+ private writeReviewEntry(
98
+ event: string,
99
+ details: PromptPermissionDetails & {
100
+ resolution?: string;
101
+ denialReason?: string;
102
+ },
103
+ ): void {
104
+ this.deps.writeReviewLog(event, {
105
+ requestId: details.requestId,
106
+ source: details.source,
107
+ agentName: details.agentName,
108
+ message: details.message,
109
+ toolCallId: details.toolCallId ?? null,
110
+ toolName: details.toolName ?? null,
111
+ skillName: details.skillName ?? null,
112
+ path: details.path ?? null,
113
+ command: details.command ?? null,
114
+ target: details.target ?? null,
115
+ toolInputPreview: details.toolInputPreview ?? null,
116
+ resolution: details.resolution ?? null,
117
+ denialReason: details.denialReason ?? null,
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Build a PermissionForwardingDeps to pass to confirmPermission.
123
+ *
124
+ * Yolo-mode is already handled at the prompter level, so shouldAutoApprove
125
+ * returns false here (confirmPermission does not call it; only
126
+ * processForwardedPermissionRequests does, and that has its own deps).
127
+ *
128
+ * The logger delegates writeReviewLog to deps and uses a no-op writeDebugLog
129
+ * (trace-level forwarding debug is deferred — see open question in the plan).
130
+ */
131
+ private buildForwardingDeps(): PermissionForwardingDeps {
132
+ const { deps } = this;
133
+ const logger: ForwardedPermissionLogger = {
134
+ writeReviewLog: deps.writeReviewLog,
135
+ writeDebugLog: () => undefined,
136
+ };
137
+ return {
138
+ forwardingDir: deps.forwardingDir,
139
+ subagentSessionsDir: deps.subagentSessionsDir,
140
+ logger,
141
+ writeReviewLog: deps.writeReviewLog,
142
+ requestPermissionDecisionFromUi: deps.requestPermissionDecisionFromUi,
143
+ shouldAutoApprove: () => false,
144
+ };
145
+ }
146
+ }
package/src/runtime.ts CHANGED
@@ -35,20 +35,16 @@ import {
35
35
  type PermissionSystemExtensionConfig,
36
36
  } from "./extension-config";
37
37
  import {
38
- confirmPermission,
39
38
  type PermissionForwardingDeps,
40
39
  processForwardedPermissionRequests,
41
40
  } from "./forwarded-permissions/polling";
42
- import type { PromptPermissionDetails } from "./handlers/types";
43
41
  import { createPermissionSystemLogger } from "./logging";
44
- import type { PermissionPromptDecision } from "./permission-dialog";
45
42
  import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
46
43
  import { PermissionManager } from "./permission-manager";
47
44
  import { SessionRules } from "./session-rules";
48
45
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
49
46
  import { syncPermissionSystemStatus } from "./status";
50
47
  import { isSubagentExecutionContext } from "./subagent-context";
51
- import { shouldAutoApprovePermissionState } from "./yolo-mode";
52
48
 
53
49
  /**
54
50
  * Runtime context object created once inside `piPermissionSystemExtension()`.
@@ -276,78 +272,6 @@ export function logResolvedConfigPaths(runtime: ExtensionRuntime): void {
276
272
  );
277
273
  }
278
274
 
279
- // ── Permission helpers ─────────────────────────────────────────────────────
280
-
281
- /** Internal: write a structured permission decision entry to the review log. */
282
- function reviewPermissionDecision(
283
- writeReviewLog: (event: string, details: Record<string, unknown>) => void,
284
- event: string,
285
- details: PromptPermissionDetails & {
286
- resolution?: string;
287
- denialReason?: string;
288
- },
289
- ): void {
290
- writeReviewLog(event, {
291
- requestId: details.requestId,
292
- source: details.source,
293
- agentName: details.agentName,
294
- message: details.message,
295
- toolCallId: details.toolCallId ?? null,
296
- toolName: details.toolName ?? null,
297
- skillName: details.skillName ?? null,
298
- path: details.path ?? null,
299
- command: details.command ?? null,
300
- target: details.target ?? null,
301
- toolInputPreview: details.toolInputPreview ?? null,
302
- resolution: details.resolution ?? null,
303
- denialReason: details.denialReason ?? null,
304
- });
305
- }
306
-
307
- /**
308
- * Prompt the user for a permission decision using the forwarding flow,
309
- * log the waiting / approved / denied outcome, and return the decision.
310
- * In yolo mode, auto-approves without prompting.
311
- */
312
- export async function promptPermission(
313
- runtime: ExtensionRuntime,
314
- forwardingDeps: PermissionForwardingDeps,
315
- ctx: ExtensionContext,
316
- details: PromptPermissionDetails,
317
- ): Promise<PermissionPromptDecision> {
318
- if (shouldAutoApprovePermissionState("ask", runtime.config)) {
319
- reviewPermissionDecision(
320
- runtime.writeReviewLog,
321
- "permission_request.auto_approved",
322
- details,
323
- );
324
- return { approved: true, state: "approved" };
325
- }
326
- reviewPermissionDecision(
327
- runtime.writeReviewLog,
328
- "permission_request.waiting",
329
- details,
330
- );
331
- const decision = await confirmPermission(
332
- ctx,
333
- details.message,
334
- forwardingDeps,
335
- details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
336
- );
337
- reviewPermissionDecision(
338
- runtime.writeReviewLog,
339
- decision.approved
340
- ? "permission_request.approved"
341
- : "permission_request.denied",
342
- {
343
- ...details,
344
- resolution: decision.state,
345
- denialReason: decision.denialReason,
346
- },
347
- );
348
- return decision;
349
- }
350
-
351
275
  // ── Forwarding polling lifecycle ───────────────────────────────────────────
352
276
 
353
277
  /** Stop the forwarded-permission polling interval and clear related state. */
@@ -0,0 +1,398 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // ── Module mocks ────────────────────────────────────────────────────────────
4
+
5
+ const { mockConfirmPermission } = vi.hoisted(() => ({
6
+ mockConfirmPermission: vi.fn(),
7
+ }));
8
+
9
+ vi.mock("../src/forwarded-permissions/polling", () => ({
10
+ confirmPermission: mockConfirmPermission,
11
+ processForwardedPermissionRequests: vi.fn().mockResolvedValue(undefined),
12
+ }));
13
+
14
+ // ── Imports (after mocks) ───────────────────────────────────────────────────
15
+
16
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
17
+ import { DEFAULT_EXTENSION_CONFIG } from "../src/extension-config";
18
+ import type { PromptPermissionDetails } from "../src/handlers/types";
19
+ import type { PermissionPromptDecision } from "../src/permission-dialog";
20
+ import {
21
+ PermissionPrompter,
22
+ type PermissionPrompterDeps,
23
+ } from "../src/permission-prompter";
24
+
25
+ // ── Helpers ─────────────────────────────────────────────────────────────────
26
+
27
+ function makeCtx(hasUI: boolean): ExtensionContext {
28
+ return {
29
+ hasUI,
30
+ ui: { select: vi.fn(), input: vi.fn() },
31
+ sessionManager: { getSessionDir: vi.fn().mockReturnValue(null) },
32
+ } as unknown as ExtensionContext;
33
+ }
34
+
35
+ function makeDetails(
36
+ overrides?: Partial<PromptPermissionDetails>,
37
+ ): PromptPermissionDetails {
38
+ return {
39
+ requestId: "req-123",
40
+ source: "tool_call",
41
+ agentName: "test-agent",
42
+ message: "Allow read?",
43
+ toolName: "read",
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ function makeDeps(
49
+ overrides?: Partial<PermissionPrompterDeps>,
50
+ ): PermissionPrompterDeps {
51
+ return {
52
+ getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: false }),
53
+ writeReviewLog: vi.fn(),
54
+ subagentSessionsDir: "/sessions/subagents",
55
+ forwardingDir: "/sessions/permission-forwarding",
56
+ requestPermissionDecisionFromUi: vi
57
+ .fn()
58
+ .mockResolvedValue({ approved: true, state: "approved" }),
59
+ ...overrides,
60
+ };
61
+ }
62
+
63
+ // ── Tests ────────────────────────────────────────────────────────────────────
64
+
65
+ describe("PermissionPrompter", () => {
66
+ beforeEach(() => {
67
+ mockConfirmPermission.mockReset();
68
+ });
69
+
70
+ // ── Yolo-mode auto-approve ───────────────────────────────────────────────
71
+
72
+ describe("yolo-mode auto-approve", () => {
73
+ it("returns approved without calling confirmPermission when yoloMode is true", async () => {
74
+ const deps = makeDeps({
75
+ getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
76
+ });
77
+ const prompter = new PermissionPrompter(deps);
78
+
79
+ const decision = await prompter.prompt(makeCtx(false), makeDetails());
80
+
81
+ expect(decision).toEqual({ approved: true, state: "approved" });
82
+ expect(mockConfirmPermission).not.toHaveBeenCalled();
83
+ });
84
+
85
+ it("logs permission_request.auto_approved in yolo mode", async () => {
86
+ const writeReviewLog = vi.fn();
87
+ const deps = makeDeps({
88
+ getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
89
+ writeReviewLog,
90
+ });
91
+ const prompter = new PermissionPrompter(deps);
92
+
93
+ await prompter.prompt(makeCtx(false), makeDetails());
94
+
95
+ expect(writeReviewLog).toHaveBeenCalledWith(
96
+ "permission_request.auto_approved",
97
+ expect.objectContaining({ requestId: "req-123" }),
98
+ );
99
+ });
100
+
101
+ it("does not log permission_request.waiting in yolo mode", async () => {
102
+ const writeReviewLog = vi.fn();
103
+ const deps = makeDeps({
104
+ getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
105
+ writeReviewLog,
106
+ });
107
+ const prompter = new PermissionPrompter(deps);
108
+
109
+ await prompter.prompt(makeCtx(false), makeDetails());
110
+
111
+ expect(writeReviewLog).not.toHaveBeenCalledWith(
112
+ "permission_request.waiting",
113
+ expect.anything(),
114
+ );
115
+ });
116
+
117
+ it("does not call confirmPermission with yoloMode even when ctx has UI", async () => {
118
+ const deps = makeDeps({
119
+ getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
120
+ });
121
+ const prompter = new PermissionPrompter(deps);
122
+
123
+ await prompter.prompt(makeCtx(true), makeDetails());
124
+
125
+ expect(mockConfirmPermission).not.toHaveBeenCalled();
126
+ });
127
+ });
128
+
129
+ // ── Non-yolo path ────────────────────────────────────────────────────────
130
+
131
+ describe("non-yolo path (UI present)", () => {
132
+ it("logs permission_request.waiting before calling confirmPermission", async () => {
133
+ const writeReviewLog = vi.fn();
134
+ const approved: PermissionPromptDecision = {
135
+ approved: true,
136
+ state: "approved",
137
+ };
138
+ mockConfirmPermission.mockResolvedValue(approved);
139
+ const deps = makeDeps({ writeReviewLog });
140
+ const prompter = new PermissionPrompter(deps);
141
+
142
+ await prompter.prompt(makeCtx(true), makeDetails());
143
+
144
+ const calls = writeReviewLog.mock.calls.map((c) => c[0] as string);
145
+ expect(
146
+ calls.indexOf("permission_request.waiting"),
147
+ ).toBeGreaterThanOrEqual(0);
148
+ expect(calls.indexOf("permission_request.waiting")).toBeLessThan(
149
+ calls.indexOf("permission_request.approved"),
150
+ );
151
+ });
152
+
153
+ it("logs permission_request.approved when confirmPermission returns approved", async () => {
154
+ const writeReviewLog = vi.fn();
155
+ mockConfirmPermission.mockResolvedValue({
156
+ approved: true,
157
+ state: "approved",
158
+ });
159
+ const deps = makeDeps({ writeReviewLog });
160
+ const prompter = new PermissionPrompter(deps);
161
+
162
+ await prompter.prompt(makeCtx(true), makeDetails());
163
+
164
+ expect(writeReviewLog).toHaveBeenCalledWith(
165
+ "permission_request.approved",
166
+ expect.objectContaining({
167
+ requestId: "req-123",
168
+ resolution: "approved",
169
+ }),
170
+ );
171
+ });
172
+
173
+ it("logs permission_request.denied when confirmPermission returns denied", async () => {
174
+ const writeReviewLog = vi.fn();
175
+ mockConfirmPermission.mockResolvedValue({
176
+ approved: false,
177
+ state: "denied",
178
+ });
179
+ const deps = makeDeps({ writeReviewLog });
180
+ const prompter = new PermissionPrompter(deps);
181
+
182
+ await prompter.prompt(makeCtx(true), makeDetails());
183
+
184
+ expect(writeReviewLog).toHaveBeenCalledWith(
185
+ "permission_request.denied",
186
+ expect.objectContaining({
187
+ requestId: "req-123",
188
+ resolution: "denied",
189
+ }),
190
+ );
191
+ });
192
+
193
+ it("logs permission_request.denied with denialReason when present", async () => {
194
+ const writeReviewLog = vi.fn();
195
+ mockConfirmPermission.mockResolvedValue({
196
+ approved: false,
197
+ state: "denied_with_reason",
198
+ denialReason: "too sensitive",
199
+ });
200
+ const deps = makeDeps({ writeReviewLog });
201
+ const prompter = new PermissionPrompter(deps);
202
+
203
+ await prompter.prompt(makeCtx(true), makeDetails());
204
+
205
+ expect(writeReviewLog).toHaveBeenCalledWith(
206
+ "permission_request.denied",
207
+ expect.objectContaining({
208
+ denialReason: "too sensitive",
209
+ }),
210
+ );
211
+ });
212
+
213
+ it("returns the decision from confirmPermission", async () => {
214
+ const decision: PermissionPromptDecision = {
215
+ approved: false,
216
+ state: "denied_with_reason",
217
+ denialReason: "sensitive",
218
+ };
219
+ mockConfirmPermission.mockResolvedValue(decision);
220
+ const deps = makeDeps();
221
+ const prompter = new PermissionPrompter(deps);
222
+
223
+ const result = await prompter.prompt(makeCtx(true), makeDetails());
224
+
225
+ expect(result).toEqual(decision);
226
+ });
227
+
228
+ it("passes sessionLabel option to confirmPermission when present", async () => {
229
+ mockConfirmPermission.mockResolvedValue({
230
+ approved: true,
231
+ state: "approved",
232
+ });
233
+ const deps = makeDeps();
234
+ const prompter = new PermissionPrompter(deps);
235
+ const details = makeDetails({ sessionLabel: "Yes, for 'read' tool" });
236
+
237
+ await prompter.prompt(makeCtx(true), details);
238
+
239
+ expect(mockConfirmPermission).toHaveBeenCalledWith(
240
+ expect.anything(),
241
+ expect.any(String),
242
+ expect.anything(),
243
+ { sessionLabel: "Yes, for 'read' tool" },
244
+ );
245
+ });
246
+
247
+ it("passes undefined options to confirmPermission when sessionLabel is absent", async () => {
248
+ mockConfirmPermission.mockResolvedValue({
249
+ approved: true,
250
+ state: "approved",
251
+ });
252
+ const deps = makeDeps();
253
+ const prompter = new PermissionPrompter(deps);
254
+
255
+ await prompter.prompt(makeCtx(true), makeDetails());
256
+
257
+ expect(mockConfirmPermission).toHaveBeenCalledWith(
258
+ expect.anything(),
259
+ expect.any(String),
260
+ expect.anything(),
261
+ undefined,
262
+ );
263
+ });
264
+
265
+ it("passes the message from details to confirmPermission", async () => {
266
+ mockConfirmPermission.mockResolvedValue({
267
+ approved: true,
268
+ state: "approved",
269
+ });
270
+ const deps = makeDeps();
271
+ const prompter = new PermissionPrompter(deps);
272
+ const details = makeDetails({ message: "Allow bash: git status?" });
273
+
274
+ await prompter.prompt(makeCtx(true), details);
275
+
276
+ expect(mockConfirmPermission).toHaveBeenCalledWith(
277
+ expect.anything(),
278
+ "Allow bash: git status?",
279
+ expect.anything(),
280
+ undefined,
281
+ );
282
+ });
283
+ });
284
+
285
+ // ── Review log field coverage ────────────────────────────────────────────
286
+
287
+ describe("review log fields", () => {
288
+ it("includes all standard fields in the waiting log entry", async () => {
289
+ const writeReviewLog = vi.fn();
290
+ mockConfirmPermission.mockResolvedValue({
291
+ approved: true,
292
+ state: "approved",
293
+ });
294
+ const deps = makeDeps({ writeReviewLog });
295
+ const prompter = new PermissionPrompter(deps);
296
+ const details = makeDetails({
297
+ toolCallId: "tc-1",
298
+ skillName: "librarian",
299
+ path: "/src/foo.ts",
300
+ command: "git status",
301
+ target: "server:tool",
302
+ toolInputPreview: "{ path: '...' }",
303
+ });
304
+
305
+ await prompter.prompt(makeCtx(true), details);
306
+
307
+ expect(writeReviewLog).toHaveBeenCalledWith(
308
+ "permission_request.waiting",
309
+ expect.objectContaining({
310
+ requestId: "req-123",
311
+ source: "tool_call",
312
+ agentName: "test-agent",
313
+ message: "Allow read?",
314
+ toolCallId: "tc-1",
315
+ toolName: "read",
316
+ skillName: "librarian",
317
+ path: "/src/foo.ts",
318
+ command: "git status",
319
+ target: "server:tool",
320
+ toolInputPreview: "{ path: '...' }",
321
+ }),
322
+ );
323
+ });
324
+
325
+ it("uses null for optional fields not present in details", async () => {
326
+ const writeReviewLog = vi.fn();
327
+ mockConfirmPermission.mockResolvedValue({
328
+ approved: true,
329
+ state: "approved",
330
+ });
331
+ const deps = makeDeps({ writeReviewLog });
332
+ const prompter = new PermissionPrompter(deps);
333
+
334
+ await prompter.prompt(makeCtx(true), makeDetails());
335
+
336
+ expect(writeReviewLog).toHaveBeenCalledWith(
337
+ "permission_request.waiting",
338
+ expect.objectContaining({
339
+ toolCallId: null,
340
+ skillName: null,
341
+ path: null,
342
+ command: null,
343
+ target: null,
344
+ toolInputPreview: null,
345
+ }),
346
+ );
347
+ });
348
+ });
349
+
350
+ // ── Subagent forwarding path ─────────────────────────────────────────────
351
+
352
+ describe("subagent forwarding path", () => {
353
+ it("calls confirmPermission even when ctx has no UI", async () => {
354
+ const forwarded: PermissionPromptDecision = {
355
+ approved: true,
356
+ state: "approved",
357
+ };
358
+ mockConfirmPermission.mockResolvedValue(forwarded);
359
+ const deps = makeDeps();
360
+ const prompter = new PermissionPrompter(deps);
361
+
362
+ await prompter.prompt(makeCtx(false), makeDetails());
363
+
364
+ expect(mockConfirmPermission).toHaveBeenCalled();
365
+ });
366
+
367
+ it("returns the decision from confirmPermission in the subagent path", async () => {
368
+ const forwarded: PermissionPromptDecision = {
369
+ approved: false,
370
+ state: "denied",
371
+ };
372
+ mockConfirmPermission.mockResolvedValue(forwarded);
373
+ const deps = makeDeps();
374
+ const prompter = new PermissionPrompter(deps);
375
+
376
+ const result = await prompter.prompt(makeCtx(false), makeDetails());
377
+
378
+ expect(result).toEqual(forwarded);
379
+ });
380
+
381
+ it("logs the outcome when confirmPermission resolves via forwarding", async () => {
382
+ const writeReviewLog = vi.fn();
383
+ mockConfirmPermission.mockResolvedValue({
384
+ approved: true,
385
+ state: "approved",
386
+ });
387
+ const deps = makeDeps({ writeReviewLog });
388
+ const prompter = new PermissionPrompter(deps);
389
+
390
+ await prompter.prompt(makeCtx(false), makeDetails());
391
+
392
+ expect(writeReviewLog).toHaveBeenCalledWith(
393
+ "permission_request.approved",
394
+ expect.objectContaining({ requestId: "req-123" }),
395
+ );
396
+ });
397
+ });
398
+ });
@@ -59,9 +59,6 @@ vi.mock("../src/config-reporter", () => ({
59
59
 
60
60
  vi.mock("../src/forwarded-permissions/polling", () => ({
61
61
  processForwardedPermissionRequests: vi.fn().mockResolvedValue(undefined),
62
- confirmPermission: vi
63
- .fn()
64
- .mockResolvedValue({ approved: true, state: "approved" }),
65
62
  }));
66
63
 
67
64
  vi.mock("../src/subagent-context", () => ({