@gotgenes/pi-permission-system 5.2.1 → 5.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "5.2.1",
3
+ "version": "5.3.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -8,6 +8,7 @@ interface InputPayload {
8
8
  text: string;
9
9
  }
10
10
 
11
+ import { emitDecisionEvent } from "../permission-events";
11
12
  import { applyPermissionGate } from "../permission-gate";
12
13
  import { formatSkillAskPrompt } from "../permission-prompts";
13
14
  import type { HandlerDeps } from "./types";
@@ -65,17 +66,22 @@ export async function handleInput(
65
66
  skillName,
66
67
  agentName ?? undefined,
67
68
  );
69
+ const skillInputCanConfirm = deps.canRequestPermissionConfirmation(ctx);
70
+ let skillInputAutoApproved = false;
68
71
  const skillInputGate = await applyPermissionGate({
69
72
  state: check.state,
70
- canConfirm: deps.canRequestPermissionConfirmation(ctx),
71
- promptForApproval: () =>
72
- deps.promptPermission(ctx, {
73
+ canConfirm: skillInputCanConfirm,
74
+ promptForApproval: async () => {
75
+ const decision = await deps.promptPermission(ctx, {
73
76
  requestId: deps.createPermissionRequestId("skill-input"),
74
77
  source: "skill_input",
75
78
  agentName,
76
79
  message: skillInputMessage,
77
80
  skillName,
78
- }),
81
+ });
82
+ skillInputAutoApproved = decision.autoApproved === true;
83
+ return decision;
84
+ },
79
85
  writeLog: deps.runtime.writeReviewLog,
80
86
  logContext: {
81
87
  source: "skill_input",
@@ -91,6 +97,27 @@ export async function handleInput(
91
97
  },
92
98
  });
93
99
 
100
+ emitDecisionEvent(deps.events, {
101
+ surface: "skill",
102
+ value: skillName,
103
+ result: skillInputGate.action === "allow" ? "allow" : "deny",
104
+ resolution:
105
+ check.state === "allow"
106
+ ? "policy_allow"
107
+ : check.state === "deny"
108
+ ? "policy_deny"
109
+ : skillInputGate.action === "allow"
110
+ ? skillInputAutoApproved
111
+ ? "auto_approved"
112
+ : "user_approved"
113
+ : skillInputCanConfirm
114
+ ? "user_denied"
115
+ : "confirmation_unavailable",
116
+ origin: check.origin ?? null,
117
+ agentName: agentName ?? null,
118
+ matchedPattern: check.matchedPattern ?? null,
119
+ });
120
+
94
121
  if (skillInputGate.action === "block") {
95
122
  return { action: "handled" };
96
123
  }
@@ -78,4 +78,5 @@ export async function handleSessionShutdown(deps: HandlerDeps): Promise<void> {
78
78
  deps.runtime.lastPromptStateCacheKey = null;
79
79
  deps.runtime.sessionRules.clear();
80
80
  deps.stopForwardedPermissionPolling();
81
+ deps.stopPermissionRpcHandlers();
81
82
  }
@@ -21,6 +21,10 @@ import {
21
21
  } from "../external-directory";
22
22
  import { suggestSessionPattern } from "../pattern-suggest";
23
23
  import type { PermissionPromptDecision } from "../permission-dialog";
24
+ import {
25
+ emitDecisionEvent,
26
+ type PermissionDecisionResolution,
27
+ } from "../permission-events";
24
28
  import { applyPermissionGate } from "../permission-gate";
25
29
  import {
26
30
  formatAskPrompt,
@@ -38,8 +42,50 @@ import {
38
42
  checkRequestedToolRegistration,
39
43
  getToolNameFromValue,
40
44
  } from "../tool-registry";
45
+ import type { PermissionCheckResult } from "../types";
41
46
  import type { HandlerDeps } from "./types";
42
47
 
48
+ // ── Emission helper ────────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Derive the human-readable value for a decision event from a check result.
52
+ * Bash → extracted command; MCP → qualified target; others → tool name.
53
+ */
54
+ function deriveDecisionValue(
55
+ toolName: string,
56
+ check: Pick<PermissionCheckResult, "command" | "target">,
57
+ ): string {
58
+ if (toolName === "bash") return check.command ?? toolName;
59
+ if (toolName === "mcp") return check.target ?? toolName;
60
+ return toolName;
61
+ }
62
+
63
+ /**
64
+ * Map the gate outcome back to a PermissionDecisionResolution.
65
+ *
66
+ * @param state - The permission state passed to the gate.
67
+ * @param action - The gate's resulting action ("allow" | "block").
68
+ * @param hasSession - True when the gate result carries a sessionApproval
69
+ * (indicates the user chose "for this session").
70
+ * @param canConfirm - Whether an interactive prompt was available.
71
+ */
72
+ function deriveResolution(
73
+ state: "allow" | "deny" | "ask",
74
+ action: "allow" | "block",
75
+ hasSession: boolean,
76
+ canConfirm: boolean,
77
+ autoApproved = false,
78
+ ): PermissionDecisionResolution {
79
+ if (state === "allow") return "policy_allow";
80
+ if (state === "deny") return "policy_deny";
81
+ // state === "ask"
82
+ if (action === "allow") {
83
+ if (autoApproved) return "auto_approved";
84
+ return hasSession ? "user_approved_for_session" : "user_approved";
85
+ }
86
+ return canConfirm ? "user_denied" : "confirmation_unavailable";
87
+ }
88
+
43
89
  /**
44
90
  * Extract the tool input from an event, checking both `input` and `arguments`
45
91
  * fields (different Pi SDK versions use different names).
@@ -112,9 +158,10 @@ export async function handleToolCall(
112
158
  readEvent.input.path,
113
159
  agentName ?? undefined,
114
160
  );
161
+ const skillReadCanConfirm = deps.canRequestPermissionConfirmation(ctx);
115
162
  const skillReadGate = await applyPermissionGate({
116
163
  state: matchedSkill.state,
117
- canConfirm: deps.canRequestPermissionConfirmation(ctx),
164
+ canConfirm: skillReadCanConfirm,
118
165
  promptForApproval: () =>
119
166
  deps.promptPermission(ctx, {
120
167
  requestId: (readEvent as { toolCallId: string }).toolCallId,
@@ -149,6 +196,20 @@ export async function handleToolCall(
149
196
  },
150
197
  },
151
198
  });
199
+ emitDecisionEvent(deps.events, {
200
+ surface: "skill",
201
+ value: matchedSkill.name,
202
+ result: skillReadGate.action === "allow" ? "allow" : "deny",
203
+ resolution: deriveResolution(
204
+ matchedSkill.state,
205
+ skillReadGate.action,
206
+ false,
207
+ skillReadCanConfirm,
208
+ ),
209
+ origin: null,
210
+ agentName: agentName ?? null,
211
+ matchedPattern: null,
212
+ });
152
213
  if (skillReadGate.action === "block") {
153
214
  return { block: true, reason: skillReadGate.reason };
154
215
  }
@@ -193,6 +254,15 @@ export async function handleToolCall(
193
254
  path: externalDirectoryPath,
194
255
  },
195
256
  );
257
+ emitDecisionEvent(deps.events, {
258
+ surface: toolName,
259
+ value: externalDirectoryPath,
260
+ result: "allow",
261
+ resolution: "infrastructure_auto_allowed",
262
+ origin: null,
263
+ agentName: agentName ?? null,
264
+ matchedPattern: null,
265
+ });
196
266
  // Fall through to normal tool-permission check.
197
267
  } else {
198
268
  const extCheck = deps.runtime.permissionManager.checkPermission(
@@ -212,6 +282,15 @@ export async function handleToolCall(
212
282
  resolution: "session_approved",
213
283
  sessionApprovalPattern: extCheck.matchedPattern,
214
284
  });
285
+ emitDecisionEvent(deps.events, {
286
+ surface: "external_directory",
287
+ value: externalDirectoryPath,
288
+ result: "allow",
289
+ resolution: "session_approved",
290
+ origin: extCheck.origin ?? null,
291
+ agentName: agentName ?? null,
292
+ matchedPattern: extCheck.matchedPattern ?? null,
293
+ });
215
294
  // Fall through to normal permission check
216
295
  } else {
217
296
  let extDirDecision: PermissionPromptDecision | null = null;
@@ -221,9 +300,10 @@ export async function handleToolCall(
221
300
  ctx.cwd,
222
301
  agentName ?? undefined,
223
302
  );
224
- const extDirGate = await applyPermissionGate({
303
+ const extDirCanConfirm = deps.canRequestPermissionConfirmation(ctx);
304
+ const extDirGateResult = await applyPermissionGate({
225
305
  state: extCheck.state,
226
- canConfirm: deps.canRequestPermissionConfirmation(ctx),
306
+ canConfirm: extDirCanConfirm,
227
307
  promptForApproval: async () => {
228
308
  const decision = await deps.promptPermission(ctx, {
229
309
  requestId: (event as { toolCallId: string }).toolCallId,
@@ -262,8 +342,22 @@ export async function handleToolCall(
262
342
  ),
263
343
  },
264
344
  });
265
- if (extDirGate.action === "block") {
266
- return { block: true, reason: extDirGate.reason };
345
+ emitDecisionEvent(deps.events, {
346
+ surface: "external_directory",
347
+ value: externalDirectoryPath,
348
+ result: extDirGateResult.action === "allow" ? "allow" : "deny",
349
+ resolution: deriveResolution(
350
+ extCheck.state,
351
+ extDirGateResult.action,
352
+ extDirDecision?.state === "approved_for_session",
353
+ extDirCanConfirm,
354
+ ),
355
+ origin: extCheck.origin ?? null,
356
+ agentName: agentName ?? null,
357
+ matchedPattern: extCheck.matchedPattern ?? null,
358
+ });
359
+ if (extDirGateResult.action === "block") {
360
+ return { block: true, reason: extDirGateResult.reason };
267
361
  }
268
362
 
269
363
  if (extDirDecision?.state === "approved_for_session") {
@@ -397,6 +491,15 @@ export async function handleToolCall(
397
491
  resolution: "session_approved",
398
492
  sessionApprovalPattern: check.matchedPattern,
399
493
  });
494
+ emitDecisionEvent(deps.events, {
495
+ surface: toolName,
496
+ value: deriveDecisionValue(toolName, check),
497
+ result: "allow",
498
+ resolution: "session_approved",
499
+ origin: check.origin ?? null,
500
+ agentName: agentName ?? null,
501
+ matchedPattern: check.matchedPattern ?? null,
502
+ });
400
503
  return {};
401
504
  }
402
505
 
@@ -423,15 +526,17 @@ export async function handleToolCall(
423
526
  : `Using tool '${toolName}' requires approval, but no interactive UI is available.`;
424
527
 
425
528
  const toolAskMessage = formatAskPrompt(check, agentName ?? undefined, input);
529
+ const toolCanConfirm = deps.canRequestPermissionConfirmation(ctx);
530
+ let toolDecisionAutoApproved = false;
426
531
  const toolGate = await applyPermissionGate({
427
532
  state: check.state,
428
- canConfirm: deps.canRequestPermissionConfirmation(ctx),
533
+ canConfirm: toolCanConfirm,
429
534
  sessionApproval: {
430
535
  surface: suggestion.surface,
431
536
  pattern: suggestion.pattern,
432
537
  },
433
- promptForApproval: () =>
434
- deps.promptPermission(ctx, {
538
+ promptForApproval: async () => {
539
+ const decision = await deps.promptPermission(ctx, {
435
540
  requestId: (event as { toolCallId: string }).toolCallId,
436
541
  source: "tool_call",
437
542
  agentName,
@@ -440,7 +545,10 @@ export async function handleToolCall(
440
545
  toolName,
441
546
  sessionLabel: suggestion.label,
442
547
  ...permissionLogContext,
443
- }),
548
+ });
549
+ toolDecisionAutoApproved = decision.autoApproved === true;
550
+ return decision;
551
+ },
444
552
  writeLog: deps.runtime.writeReviewLog,
445
553
  logContext: {
446
554
  source: "tool_call",
@@ -458,6 +566,24 @@ export async function handleToolCall(
458
566
  },
459
567
  });
460
568
 
569
+ const toolGateHasSession =
570
+ toolGate.action === "allow" && toolGate.sessionApproval !== undefined;
571
+ emitDecisionEvent(deps.events, {
572
+ surface: toolName,
573
+ value: deriveDecisionValue(toolName, check),
574
+ result: toolGate.action === "allow" ? "allow" : "deny",
575
+ resolution: deriveResolution(
576
+ check.state,
577
+ toolGate.action,
578
+ toolGateHasSession,
579
+ toolCanConfirm,
580
+ toolDecisionAutoApproved,
581
+ ),
582
+ origin: check.origin ?? null,
583
+ agentName: agentName ?? null,
584
+ matchedPattern: check.matchedPattern ?? null,
585
+ });
586
+
461
587
  if (toolGate.action === "block") {
462
588
  return { block: true, reason: toolGate.reason };
463
589
  }
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
2
 
3
3
  import type { PermissionPromptDecision } from "../permission-dialog";
4
+ import type { PermissionEventBus } from "../permission-events";
4
5
  import type { PermissionManager } from "../permission-manager";
5
6
  import type { ExtensionRuntime } from "../runtime";
6
7
 
@@ -33,6 +34,8 @@ export interface HandlerDeps {
33
34
  // ── Runtime context ────────────────────────────────────────────────────
34
35
  /** All mutable extension state and log-writing methods. */
35
36
  readonly runtime: ExtensionRuntime;
37
+ /** Event bus for emitting permissions:decision broadcast events. */
38
+ readonly events: PermissionEventBus;
36
39
 
37
40
  // ── Factories ──────────────────────────────────────────────────────────
38
41
  /** Create a new PermissionManager scoped to cwd's config hierarchy. */
@@ -67,6 +70,8 @@ export interface HandlerDeps {
67
70
  // ── Forwarding ─────────────────────────────────────────────────────────
68
71
  startForwardedPermissionPolling(ctx: ExtensionContext): void;
69
72
  stopForwardedPermissionPolling(): void;
73
+ /** Unsubscribe the permissions:rpc:check and permissions:rpc:prompt handlers. */
74
+ stopPermissionRpcHandlers(): void;
70
75
 
71
76
  // ── Pi API subset ──────────────────────────────────────────────────────
72
77
  getAllTools(): unknown[];
package/src/index.ts CHANGED
@@ -12,6 +12,8 @@ import {
12
12
  handleToolCall,
13
13
  } from "./handlers";
14
14
  import { requestPermissionDecisionFromUi } from "./permission-dialog";
15
+ import { registerPermissionRpcHandlers } from "./permission-event-rpc";
16
+ import { emitReadyEvent } from "./permission-events";
15
17
  import { PermissionPrompter } from "./permission-prompter";
16
18
  import {
17
19
  createExtensionRuntime,
@@ -67,8 +69,17 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
67
69
  const createPermissionRequestId = (prefix: string): string =>
68
70
  `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
69
71
 
72
+ const rpcHandles = registerPermissionRpcHandlers(pi.events, {
73
+ getPermissionManager: () => runtime.permissionManager,
74
+ getSessionRules: () => runtime.sessionRules.getRuleset(),
75
+ getRuntimeContext: () => runtime.runtimeContext,
76
+ requestPermissionDecisionFromUi,
77
+ writeReviewLog: runtime.writeReviewLog.bind(runtime),
78
+ });
79
+
70
80
  const deps: HandlerDeps = {
71
81
  runtime,
82
+ events: pi.events,
72
83
  createPermissionManagerForCwd: (cwd) =>
73
84
  createPermissionManagerForCwd(runtime.agentDir, cwd),
74
85
  refreshExtensionConfig: (ctx) => refreshExtensionConfig(runtime, ctx),
@@ -92,10 +103,16 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
92
103
  startForwardedPermissionPolling(runtime, forwardingDeps, ctx),
93
104
  stopForwardedPermissionPolling: () =>
94
105
  stopForwardedPermissionPolling(runtime),
106
+ stopPermissionRpcHandlers: () => {
107
+ rpcHandles.unsubCheck();
108
+ rpcHandles.unsubPrompt();
109
+ },
95
110
  getAllTools: () => pi.getAllTools(),
96
111
  setActiveTools: (names) => pi.setActiveTools(names),
97
112
  };
98
113
 
114
+ emitReadyEvent(pi.events);
115
+
99
116
  pi.on("session_start", (event, ctx) => handleSessionStart(deps, event, ctx));
100
117
  pi.on("resources_discover", (event) => handleResourcesDiscover(deps, event));
101
118
  pi.on("session_shutdown", () => handleSessionShutdown(deps));
@@ -8,6 +8,12 @@ export type PermissionPromptDecision = {
8
8
  approved: boolean;
9
9
  state: PermissionDecisionState;
10
10
  denialReason?: string;
11
+ /**
12
+ * True when the decision was made automatically by yolo mode rather than
13
+ * by an interactive user prompt. Used by handlers to emit "auto_approved"
14
+ * rather than "user_approved" in the permissions:decision broadcast.
15
+ */
16
+ autoApproved?: true;
11
17
  };
12
18
 
13
19
  export interface PermissionDecisionUi {
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Permission event bus RPC handlers.
3
+ *
4
+ * Registers `permissions:rpc:check` and `permissions:rpc:prompt` handlers on
5
+ * the Pi event bus so other extensions can query our policy and forward
6
+ * permission prompts without importing this package.
7
+ */
8
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
9
+ import type {
10
+ PermissionPromptDecision,
11
+ RequestPermissionOptions,
12
+ } from "./permission-dialog";
13
+ import type {
14
+ PermissionEventBus,
15
+ PermissionsCheckReplyData,
16
+ PermissionsCheckRequest,
17
+ PermissionsPromptReplyData,
18
+ PermissionsPromptRequest,
19
+ PermissionsRpcReply,
20
+ } from "./permission-events";
21
+ import {
22
+ PERMISSIONS_PROTOCOL_VERSION,
23
+ PERMISSIONS_RPC_CHECK_CHANNEL,
24
+ PERMISSIONS_RPC_PROMPT_CHANNEL,
25
+ } from "./permission-events";
26
+ import type { PermissionManager } from "./permission-manager";
27
+ import type { Rule } from "./rule";
28
+
29
+ /** Dependencies injected into the RPC handler registry. */
30
+ export interface PermissionRpcDeps {
31
+ /** Returns the current PermissionManager (refreshed on session start). */
32
+ getPermissionManager(): Pick<PermissionManager, "checkPermission">;
33
+ /** Returns the current session rules (highest-priority approvals). */
34
+ getSessionRules(): Rule[];
35
+ /**
36
+ * Returns the current ExtensionContext, or null if no session is active.
37
+ * Used by the prompt handler to check hasUI and access the UI dialog.
38
+ */
39
+ getRuntimeContext(): ExtensionContext | null;
40
+ /** Show the interactive permission dialog in the parent session UI. */
41
+ requestPermissionDecisionFromUi(
42
+ ui: ExtensionContext["ui"],
43
+ title: string,
44
+ message: string,
45
+ options?: RequestPermissionOptions,
46
+ ): Promise<PermissionPromptDecision>;
47
+ /** Write structured entries to the permission review log. */
48
+ writeReviewLog(event: string, details: Record<string, unknown>): void;
49
+ }
50
+
51
+ /** Unsubscribe handles returned from registerPermissionRpcHandlers. */
52
+ export interface PermissionRpcHandles {
53
+ /** Stop the permissions:rpc:check handler. */
54
+ unsubCheck: () => void;
55
+ /** Stop the permissions:rpc:prompt handler. */
56
+ unsubPrompt: () => void;
57
+ }
58
+
59
+ // ── Internal helpers ───────────────────────────────────────────────────────
60
+
61
+ /** Build a success reply envelope. */
62
+ function successReply<T>(data?: T): PermissionsRpcReply<T> {
63
+ if (data !== undefined) {
64
+ return {
65
+ success: true,
66
+ protocolVersion: PERMISSIONS_PROTOCOL_VERSION,
67
+ data,
68
+ };
69
+ }
70
+ return { success: true, protocolVersion: PERMISSIONS_PROTOCOL_VERSION };
71
+ }
72
+
73
+ /** Build an error reply envelope. */
74
+ function errorReply(error: string): PermissionsRpcReply {
75
+ return {
76
+ success: false,
77
+ protocolVersion: PERMISSIONS_PROTOCOL_VERSION,
78
+ error,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Construct a surface-appropriate input object from a raw value string.
84
+ *
85
+ * Note: MCP inputs are complex (server name + tool name derivation). Callers
86
+ * providing an MCP surface receive a best-effort policy evaluation using the
87
+ * value as a pre-qualified target string. Pass the fully-qualified target
88
+ * (e.g. "exa:search" or "exa") directly.
89
+ */
90
+ function buildInputForSurface(
91
+ surface: string,
92
+ value: string | undefined,
93
+ ): unknown {
94
+ const v = value ?? "";
95
+ if (surface === "bash") return { command: v };
96
+ if (surface === "skill") return { name: v };
97
+ if (surface === "external_directory") return { path: v };
98
+ // MCP and tool surfaces: normalizeInput handles them from the surface alone.
99
+ return {};
100
+ }
101
+
102
+ // ── RPC handler: permissions:rpc:check ────────────────────────────────────
103
+
104
+ function handleCheckRpc(
105
+ raw: unknown,
106
+ events: PermissionEventBus,
107
+ deps: PermissionRpcDeps,
108
+ ): void {
109
+ const req = raw as Partial<PermissionsCheckRequest>;
110
+ const { requestId, surface, value, agentName } = req;
111
+
112
+ if (typeof requestId !== "string" || !requestId) {
113
+ // Cannot reply without a requestId — silently discard.
114
+ return;
115
+ }
116
+
117
+ const replyChannel = `${PERMISSIONS_RPC_CHECK_CHANNEL}:reply:${requestId}`;
118
+
119
+ try {
120
+ if (typeof surface !== "string" || !surface) {
121
+ events.emit(replyChannel, errorReply("surface is required"));
122
+ return;
123
+ }
124
+
125
+ const input = buildInputForSurface(surface, value);
126
+ const sessionRules = deps.getSessionRules();
127
+ const result = deps
128
+ .getPermissionManager()
129
+ .checkPermission(surface, input, agentName ?? undefined, sessionRules);
130
+
131
+ const data: PermissionsCheckReplyData = {
132
+ result: result.state,
133
+ matchedPattern: result.matchedPattern ?? null,
134
+ origin: result.origin ?? null,
135
+ };
136
+ events.emit(replyChannel, successReply(data));
137
+ } catch (err) {
138
+ const message = err instanceof Error ? err.message : String(err);
139
+ events.emit(replyChannel, errorReply(message));
140
+ }
141
+ }
142
+
143
+ // ── RPC handler: permissions:rpc:prompt ───────────────────────────────────
144
+
145
+ async function handlePromptRpc(
146
+ raw: unknown,
147
+ events: PermissionEventBus,
148
+ deps: PermissionRpcDeps,
149
+ ): Promise<void> {
150
+ const req = raw as Partial<PermissionsPromptRequest>;
151
+ const { requestId, surface, value, agentName, message, sessionLabel } = req;
152
+
153
+ if (typeof requestId !== "string" || !requestId) {
154
+ return;
155
+ }
156
+
157
+ const replyChannel = `${PERMISSIONS_RPC_PROMPT_CHANNEL}:reply:${requestId}`;
158
+
159
+ const ctx = deps.getRuntimeContext();
160
+ if (!ctx?.hasUI) {
161
+ events.emit(replyChannel, errorReply("no_ui"));
162
+ return;
163
+ }
164
+
165
+ if (typeof message !== "string" || !message) {
166
+ events.emit(replyChannel, errorReply("message is required"));
167
+ return;
168
+ }
169
+
170
+ try {
171
+ const title = surface
172
+ ? `Permission request${agentName ? ` from ${agentName}` : ""}`
173
+ : "Permission request";
174
+
175
+ const decision = await deps.requestPermissionDecisionFromUi(
176
+ ctx.ui,
177
+ title,
178
+ message,
179
+ sessionLabel ? { sessionLabel } : undefined,
180
+ );
181
+
182
+ deps.writeReviewLog("permission_request.rpc_prompt", {
183
+ requestId,
184
+ surface: surface ?? null,
185
+ value: value ?? null,
186
+ agentName: agentName ?? null,
187
+ message,
188
+ approved: decision.approved,
189
+ resolution: decision.state,
190
+ denialReason: decision.denialReason ?? null,
191
+ });
192
+
193
+ const data: PermissionsPromptReplyData = {
194
+ approved: decision.approved,
195
+ state: decision.state,
196
+ ...(decision.denialReason !== undefined
197
+ ? { denialReason: decision.denialReason }
198
+ : {}),
199
+ };
200
+ events.emit(replyChannel, successReply(data));
201
+ } catch (err) {
202
+ const message_ = err instanceof Error ? err.message : String(err);
203
+ events.emit(replyChannel, errorReply(message_));
204
+ }
205
+ }
206
+
207
+ // ── Public API ─────────────────────────────────────────────────────────────
208
+
209
+ /**
210
+ * Register `permissions:rpc:check` and `permissions:rpc:prompt` handlers on
211
+ * the event bus.
212
+ *
213
+ * Returns unsubscribe handles — call them in session_shutdown to stop the
214
+ * handlers and prevent memory leaks.
215
+ */
216
+ export function registerPermissionRpcHandlers(
217
+ events: PermissionEventBus,
218
+ deps: PermissionRpcDeps,
219
+ ): PermissionRpcHandles {
220
+ const unsubCheck = events.on(PERMISSIONS_RPC_CHECK_CHANNEL, (raw) => {
221
+ handleCheckRpc(raw, events, deps);
222
+ });
223
+
224
+ const unsubPrompt = events.on(PERMISSIONS_RPC_PROMPT_CHANNEL, (raw) => {
225
+ void handlePromptRpc(raw, events, deps);
226
+ });
227
+
228
+ return { unsubCheck, unsubPrompt };
229
+ }