@gotgenes/pi-permission-system 5.2.1 → 5.3.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,27 @@ 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
+ ## [5.3.0](https://github.com/gotgenes/pi-permission-system/compare/v5.2.1...v5.3.0) (2026-05-05)
9
+
10
+
11
+ ### Features
12
+
13
+ * add permission event types and emit helpers ([#29](https://github.com/gotgenes/pi-permission-system/issues/29)) ([45a4158](https://github.com/gotgenes/pi-permission-system/commit/45a415833818b78651671f6a33103e874cc137be))
14
+ * add permissions:rpc:check policy query RPC ([#29](https://github.com/gotgenes/pi-permission-system/issues/29)) ([b230ff8](https://github.com/gotgenes/pi-permission-system/commit/b230ff8078679d85a63f14888f3feb36054201ff))
15
+ * add permissions:rpc:prompt forwarding RPC ([#29](https://github.com/gotgenes/pi-permission-system/issues/29)) ([438227c](https://github.com/gotgenes/pi-permission-system/commit/438227c090d620c948cf91d26d8cf0b85ec9b66e))
16
+ * clean up RPC handlers on session shutdown ([#29](https://github.com/gotgenes/pi-permission-system/issues/29)) ([0a54a10](https://github.com/gotgenes/pi-permission-system/commit/0a54a108a824168bfab7059c3d78bd182dbbaccd))
17
+ * distinguish auto-approved from user-approved in decision events ([#29](https://github.com/gotgenes/pi-permission-system/issues/29)) ([746d988](https://github.com/gotgenes/pi-permission-system/commit/746d988fc0af4dd06e507b79069bf199b1c7dfd7))
18
+ * emit permission decision events from input handler ([#29](https://github.com/gotgenes/pi-permission-system/issues/29)) ([bfc21bb](https://github.com/gotgenes/pi-permission-system/commit/bfc21bba8c81aff58655df8928c43c4e68e9a2af))
19
+ * emit permission decision events from tool-call handler ([#29](https://github.com/gotgenes/pi-permission-system/issues/29)) ([40cf12e](https://github.com/gotgenes/pi-permission-system/commit/40cf12e78fcd52a37f7da46d75fb1c1b2a26d15e))
20
+ * emit permissions:ready on extension load ([#29](https://github.com/gotgenes/pi-permission-system/issues/29)) ([bfa3606](https://github.com/gotgenes/pi-permission-system/commit/bfa360633b25048e7f435141700dbbecaf77274c))
21
+
22
+
23
+ ### Documentation
24
+
25
+ * document permission event API and RPC protocol ([#29](https://github.com/gotgenes/pi-permission-system/issues/29)) ([3872e54](https://github.com/gotgenes/pi-permission-system/commit/3872e5416bfb118b4ab5538a122ac3f9bccf40d7))
26
+ * plan permission event channel with decision broadcast and RPC ([#29](https://github.com/gotgenes/pi-permission-system/issues/29)) ([d31754a](https://github.com/gotgenes/pi-permission-system/commit/d31754ad90c02c11f6b92fcc3c33f8dbf76ccee3))
27
+ * **retro:** add retro notes for issue [#97](https://github.com/gotgenes/pi-permission-system/issues/97) ([43d66d9](https://github.com/gotgenes/pi-permission-system/commit/43d66d932fc4ed121a6789b241112c1bd6c777f0))
28
+
8
29
  ## [5.2.1](https://github.com/gotgenes/pi-permission-system/compare/v5.2.0...v5.2.1) (2026-05-05)
9
30
 
10
31
 
package/README.md CHANGED
@@ -687,6 +687,172 @@ npx --yes ajv-cli@5 validate \
687
687
 
688
688
  ---
689
689
 
690
+ ## Event API
691
+
692
+ The extension emits events on Pi's `pi.events` bus so other extensions can observe permission decisions and integrate with the policy system without importing this package.
693
+
694
+ ### Stability guarantee
695
+
696
+ Fields may be added to any payload, but existing fields will not be removed or renamed without a semver-major version bump.
697
+ The protocol version constant is exported from `src/permission-events.ts` and embedded in every RPC reply.
698
+
699
+ ### Channel reference
700
+
701
+ |Channel|Direction|When|Payload type|
702
+ |---|---|---|---|
703
+ |`permissions:ready`|Broadcast|Once, immediately after load|`PermissionsReadyEvent`|
704
+ |`permissions:decision`|Broadcast|After every gate resolution|`PermissionDecisionEvent`|
705
+ |`permissions:rpc:check`|Request|On-demand|`PermissionsCheckRequest`|
706
+ |`permissions:rpc:check:reply:<requestId>`|Reply|After each check request|`PermissionsRpcReply<PermissionsCheckReplyData>`|
707
+ |`permissions:rpc:prompt`|Request|On-demand|`PermissionsPromptRequest`|
708
+ |`permissions:rpc:prompt:reply:<requestId>`|Reply|After prompt is resolved|`PermissionsRpcReply<PermissionsPromptReplyData>`|
709
+
710
+ ### Surface 1 — Decision broadcasts
711
+
712
+ Every permission gate resolution emits a `permissions:decision` event, regardless of outcome.
713
+ This is useful for dashboards, telemetry, or audit overlays.
714
+
715
+ ```typescript
716
+ pi.events.on("permissions:decision", (raw) => {
717
+ const event = raw as import("@gotgenes/pi-permission-system").PermissionDecisionEvent;
718
+ console.log(event.surface, event.result, event.resolution);
719
+ // e.g. "bash" "allow" "user_approved_for_session"
720
+ });
721
+ ```
722
+
723
+ Payload fields:
724
+
725
+ |Field|Type|Description|
726
+ |---|---|---|
727
+ |`surface`|`string`|Permission surface (`"bash"`, `"read"`, `"mcp"`, `"skill"`, `"external_directory"`, etc.)|
728
+ |`value`|`string`|Value evaluated (command, tool name, skill name, path)|
729
+ |`result`|`"allow" \| "deny"`|Final outcome|
730
+ |`resolution`|`string`|How the outcome was reached (see table below)|
731
+ |`origin`|`string \| null`|Config scope that contributed the winning rule|
732
+ |`agentName`|`string \| null`|Active agent name when known|
733
+ |`matchedPattern`|`string \| null`|Pattern from the winning rule|
734
+
735
+ Resolution values:
736
+
737
+ |Value|Meaning|
738
+ |---|---|
739
+ |`policy_allow`|Config rule said allow — no prompt shown|
740
+ |`policy_deny`|Config rule said deny — blocked immediately|
741
+ |`session_approved`|Covered by a session-level approval from earlier in the same session|
742
+ |`infrastructure_auto_allowed`|Read of a Pi infrastructure path — auto-allowed|
743
+ |`user_approved`|User approved once via dialog|
744
+ |`user_approved_for_session`|User approved for the rest of the session|
745
+ |`user_denied`|User denied via dialog|
746
+ |`auto_approved`|Yolo mode — approved automatically without dialog|
747
+ |`confirmation_unavailable`|State was `ask` but no UI was available — blocked|
748
+
749
+ ### Surface 2 — Policy query RPC
750
+
751
+ Other extensions can evaluate the current permission policy without importing this package.
752
+ The call is synchronous-style: emit a request, listen on a scoped reply channel.
753
+
754
+ ```typescript
755
+ const requestId = crypto.randomUUID();
756
+
757
+ // Listen for the reply first
758
+ const unsub = pi.events.on(
759
+ `permissions:rpc:check:reply:${requestId}`,
760
+ (raw) => {
761
+ unsub();
762
+ const reply = raw as import("@gotgenes/pi-permission-system").PermissionsRpcReply<
763
+ import("@gotgenes/pi-permission-system").PermissionsCheckReplyData
764
+ >;
765
+ if (reply.success) {
766
+ console.log(reply.data?.result); // "allow" | "deny" | "ask"
767
+ }
768
+ },
769
+ );
770
+
771
+ // Then emit the request
772
+ pi.events.emit("permissions:rpc:check", {
773
+ requestId,
774
+ surface: "bash",
775
+ value: "git push",
776
+ agentName: "Worker", // optional
777
+ });
778
+ ```
779
+
780
+ If the extension is not loaded, no reply arrives.
781
+ Callers should implement a timeout and treat no-reply as `deny` (graceful degradation).
782
+
783
+ Request fields:
784
+
785
+ |Field|Required|Description|
786
+ |---|---|---|
787
+ |`requestId`|Yes|Unique string; scopes the reply channel|
788
+ |`surface`|Yes|Permission surface to evaluate|
789
+ |`value`|No|Value to evaluate (command, name, path); defaults to `"*"`|
790
+ |`agentName`|No|Agent name for per-agent policy resolution|
791
+
792
+ Reply data fields (`PermissionsCheckReplyData`):
793
+
794
+ |Field|Type|Description|
795
+ |---|---|---|
796
+ |`result`|`"allow" \| "deny" \| "ask"`|Policy decision (including active session rules)|
797
+ |`matchedPattern`|`string \| null`|Matched rule pattern|
798
+ |`origin`|`string \| null`|Config scope of the winning rule|
799
+
800
+ ### Surface 3 — Prompt forwarding RPC
801
+
802
+ In-process child sessions (e.g. tintinweb/pi-subagents running via `createAgentSession()`) cannot use file-based permission forwarding because no child process is spawned.
803
+ They can instead forward permission prompts to the parent session's UI via this RPC.
804
+
805
+ ```typescript
806
+ const requestId = crypto.randomUUID();
807
+
808
+ const unsub = pi.events.on(
809
+ `permissions:rpc:prompt:reply:${requestId}`,
810
+ (raw) => {
811
+ unsub();
812
+ const reply = raw as import("@gotgenes/pi-permission-system").PermissionsRpcReply<
813
+ import("@gotgenes/pi-permission-system").PermissionsPromptReplyData
814
+ >;
815
+ if (reply.success && reply.data?.approved) {
816
+ // proceed
817
+ } else {
818
+ // deny — either user denied or no UI was available (error: "no_ui")
819
+ }
820
+ },
821
+ );
822
+
823
+ pi.events.emit("permissions:rpc:prompt", {
824
+ requestId,
825
+ surface: "bash",
826
+ value: "rm -rf /tmp/build",
827
+ message: "Allow rm -rf /tmp/build?",
828
+ agentName: "Explore", // optional
829
+ sessionLabel: "Allow rm *", // optional — label for the \"for this session\" option
830
+ });
831
+ ```
832
+
833
+ The handler replies with `{ success: false, error: "no_ui" }` when no interactive session is available.
834
+ A successful reply contains:
835
+
836
+ |Field|Type|Description|
837
+ |---|---|---|
838
+ |`approved`|`boolean`|Whether the user approved|
839
+ |`state`|`string`|`"approved"`, `"approved_for_session"`, `"denied"`, or `"denied_with_reason"`|
840
+ |`denialReason`|`string` (optional)|User-provided denial reason|
841
+
842
+ ### Ready event
843
+
844
+ The extension emits `permissions:ready` once immediately after it loads.
845
+ Consumers that start after the extension can check via a ping-style RPC check — the `permissions:rpc:check` handler is active as long as the extension is loaded.
846
+
847
+ ```typescript
848
+ pi.events.on("permissions:ready", (raw) => {
849
+ const event = raw as import("@gotgenes/pi-permission-system").PermissionsReadyEvent;
850
+ console.log("Permission system loaded, protocol version:", event.protocolVersion);
851
+ });
852
+ ```
853
+
854
+ ---
855
+
690
856
  ## Migration from pre-v2 layout
691
857
 
692
858
  Before v2, config was split across two files:
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.0",
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 {