@gotgenes/pi-permission-system 18.1.0 → 18.1.2

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 (132) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/docs/assets/logo.png +0 -0
  3. package/docs/assets/logo.svg +213 -0
  4. package/docs/configuration.md +700 -0
  5. package/docs/cross-extension-api.md +525 -0
  6. package/docs/guides/permission-frontmatter-for-subagent-extensions.md +201 -0
  7. package/docs/guides/upstream-issue-template.md +113 -0
  8. package/docs/migration/legacy-to-flat.md +365 -0
  9. package/docs/opencode-compatibility.md +213 -0
  10. package/docs/session-approvals.md +68 -0
  11. package/docs/subagent-integration.md +102 -0
  12. package/docs/troubleshooting.md +53 -0
  13. package/package.json +5 -2
  14. package/src/access-intent/access-path.ts +29 -4
  15. package/src/access-intent/bash/bash-path-resolver.ts +39 -18
  16. package/src/access-intent/bash/msys-bash-tokens.ts +64 -0
  17. package/src/path-normalizer.ts +105 -2
  18. package/test/access-intent/access-path.test.ts +0 -277
  19. package/test/access-intent/bash/node-text.test.ts +0 -148
  20. package/test/access-intent/bash/parser.test.ts +0 -19
  21. package/test/access-intent/bash/program.test.ts +0 -673
  22. package/test/access-intent/bash/token-classification.test.ts +0 -363
  23. package/test/access-intent/bash/token-collection.test.ts +0 -300
  24. package/test/active-agent.test.ts +0 -155
  25. package/test/async-cache.test.ts +0 -48
  26. package/test/bash-arity.test.ts +0 -144
  27. package/test/bash-external-directory.test.ts +0 -1022
  28. package/test/builtin-tool-input-formatters.test.ts +0 -109
  29. package/test/canonicalize-path.test.ts +0 -119
  30. package/test/composition-root.test.ts +0 -698
  31. package/test/config-loader.test.ts +0 -740
  32. package/test/config-modal.test.ts +0 -320
  33. package/test/config-paths.test.ts +0 -83
  34. package/test/config-pipeline.test.ts +0 -90
  35. package/test/config-reporter.test.ts +0 -147
  36. package/test/config-store.test.ts +0 -466
  37. package/test/decision-audit.test.ts +0 -72
  38. package/test/decision-reporter.test.ts +0 -112
  39. package/test/denial-messages.test.ts +0 -714
  40. package/test/detect-permissive-bash-fallback.test.ts +0 -56
  41. package/test/expand-home.test.ts +0 -93
  42. package/test/extension-config.test.ts +0 -129
  43. package/test/extension-paths.test.ts +0 -108
  44. package/test/forwarded-permissions/io.test.ts +0 -251
  45. package/test/forwarding-manager.test.ts +0 -200
  46. package/test/handlers/before-agent-start.test.ts +0 -314
  47. package/test/handlers/external-directory-integration.test.ts +0 -515
  48. package/test/handlers/external-directory-session-dedup.test.ts +0 -175
  49. package/test/handlers/external-directory-symlink-acceptance.test.ts +0 -167
  50. package/test/handlers/gates/bash-command-metamorphic.test.ts +0 -88
  51. package/test/handlers/gates/bash-command.test.ts +0 -257
  52. package/test/handlers/gates/bash-external-directory.test.ts +0 -268
  53. package/test/handlers/gates/bash-path.test.ts +0 -346
  54. package/test/handlers/gates/candidate-check.test.ts +0 -52
  55. package/test/handlers/gates/external-directory-messages.test.ts +0 -85
  56. package/test/handlers/gates/external-directory-policy.test.ts +0 -134
  57. package/test/handlers/gates/external-directory.test.ts +0 -267
  58. package/test/handlers/gates/helpers.test.ts +0 -165
  59. package/test/handlers/gates/path.test.ts +0 -369
  60. package/test/handlers/gates/runner.test.ts +0 -408
  61. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +0 -176
  62. package/test/handlers/gates/skill-input.test.ts +0 -128
  63. package/test/handlers/gates/skill-read.test.ts +0 -161
  64. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +0 -356
  65. package/test/handlers/gates/tool.test.ts +0 -244
  66. package/test/handlers/input-events.test.ts +0 -168
  67. package/test/handlers/input.test.ts +0 -199
  68. package/test/handlers/lifecycle.test.ts +0 -221
  69. package/test/handlers/tool-call-boundary.test.ts +0 -145
  70. package/test/handlers/tool-call-events.test.ts +0 -277
  71. package/test/handlers/tool-call.test.ts +0 -395
  72. package/test/handlers/validate-requested-tool.test.ts +0 -92
  73. package/test/helpers/external-directory-fixtures.ts +0 -269
  74. package/test/helpers/gate-fixtures.ts +0 -316
  75. package/test/helpers/handler-fixtures.ts +0 -335
  76. package/test/helpers/make-fake-pi.ts +0 -100
  77. package/test/helpers/manager-harness.ts +0 -112
  78. package/test/helpers/session-fixtures.ts +0 -199
  79. package/test/input-normalizer.test.ts +0 -325
  80. package/test/logging.test.ts +0 -51
  81. package/test/mcp-targets.test.ts +0 -233
  82. package/test/node-modules-discovery.test.ts +0 -97
  83. package/test/normalize.test.ts +0 -247
  84. package/test/path-containment.test.ts +0 -161
  85. package/test/path-normalization.test.ts +0 -233
  86. package/test/path-normalizer.test.ts +0 -196
  87. package/test/path-surfaces.test.ts +0 -55
  88. package/test/pattern-suggest.test.ts +0 -248
  89. package/test/permission-dialog.test.ts +0 -205
  90. package/test/permission-event-rpc.test.ts +0 -560
  91. package/test/permission-events.test.ts +0 -400
  92. package/test/permission-forwarder.test.ts +0 -370
  93. package/test/permission-forwarding.test.ts +0 -315
  94. package/test/permission-gate.test.ts +0 -269
  95. package/test/permission-manager-unified.test.ts +0 -3714
  96. package/test/permission-merge.test.ts +0 -61
  97. package/test/permission-prompter.test.ts +0 -518
  98. package/test/permission-prompts.test.ts +0 -363
  99. package/test/permission-resolver.test.ts +0 -277
  100. package/test/permission-session.test.ts +0 -404
  101. package/test/permission-ui-prompt.test.ts +0 -146
  102. package/test/permissions-service.test.ts +0 -192
  103. package/test/pi-infrastructure-read.test.ts +0 -432
  104. package/test/policy-loader.test.ts +0 -561
  105. package/test/prompting-gateway.test.ts +0 -231
  106. package/test/rule.test.ts +0 -650
  107. package/test/safe-system-paths.test.ts +0 -46
  108. package/test/scope-merge.test.ts +0 -116
  109. package/test/service-lifecycle.test.ts +0 -163
  110. package/test/service.test.ts +0 -261
  111. package/test/session-approval.test.ts +0 -75
  112. package/test/session-logger.test.ts +0 -200
  113. package/test/session-rules.test.ts +0 -321
  114. package/test/session-start.test.ts +0 -112
  115. package/test/skill-prompt-sanitizer.test.ts +0 -418
  116. package/test/status.test.ts +0 -10
  117. package/test/subagent-context.test.ts +0 -372
  118. package/test/subagent-lifecycle-events.test.ts +0 -132
  119. package/test/subagent-registry.test.ts +0 -145
  120. package/test/synthesize.test.ts +0 -302
  121. package/test/system-prompt-sanitizer.test.ts +0 -382
  122. package/test/tool-access-extractor-registry.test.ts +0 -77
  123. package/test/tool-input-formatter-registry.test.ts +0 -75
  124. package/test/tool-input-path.test.ts +0 -84
  125. package/test/tool-input-preview.test.ts +0 -129
  126. package/test/tool-input-prompt-formatters.test.ts +0 -115
  127. package/test/tool-preview-formatter.test.ts +0 -458
  128. package/test/tool-registry.test.ts +0 -197
  129. package/test/value-guards.test.ts +0 -193
  130. package/test/wildcard-matcher.test.ts +0 -424
  131. package/test/yaml-frontmatter.test.ts +0 -91
  132. package/test/yolo-mode.test.ts +0 -188
@@ -0,0 +1,525 @@
1
+ # Event API
2
+
3
+ The extension provides two cross-extension integration surfaces:
4
+
5
+ 1. **Service accessor** (preferred) — a `Symbol.for()`-backed synchronous API on `globalThis` for direct policy queries.
6
+ 2. **Event bus** — broadcasts and RPC on `pi.events` for observation and prompt forwarding.
7
+
8
+ ---
9
+
10
+ ## Service Accessor
11
+
12
+ The preferred way for other extensions to query the permission policy is the `Symbol.for()`-backed service accessor.
13
+ It provides direct, synchronous, type-safe function calls — no async RPC envelope needed.
14
+
15
+ ### Quick Start
16
+
17
+ ```typescript
18
+ try {
19
+ const { getPermissionsService } = await import(
20
+ "@gotgenes/pi-permission-system"
21
+ );
22
+ const permissions = getPermissionsService();
23
+ if (permissions) {
24
+ const result = permissions.checkPermission("bash", "git push");
25
+ console.log(result.state); // "allow" | "deny" | "ask"
26
+ }
27
+ } catch {
28
+ // Not installed — graceful degradation
29
+ }
30
+ ```
31
+
32
+ ### How It Works
33
+
34
+ Pi's extension loader creates a fresh [jiti](https://github.com/nicolo-ribaudo/jiti) instance per extension with `moduleCache: false`, which isolates module-level state.
35
+ `Symbol.for()` and `globalThis` are process-global by spec, so they survive this isolation.
36
+
37
+ The permission-system extension publishes a service object on `globalThis` via `Symbol.for("@gotgenes/pi-permission-system:service")` at `session_start`.
38
+ Consumers call `getPermissionsService()` to retrieve it — even though their `import()` loads a fresh module copy, the accessor reads from the shared `globalThis` slot.
39
+ An in-process subagent child does not publish its own service; inside a child, `getPermissionsService()` resolves the parent's service.
40
+ A consumer reacting to the `permissions:ready` broadcast (also emitted at `session_start`, after the publish) can resolve the service immediately.
41
+
42
+ ### API
43
+
44
+ The `PermissionsService` interface:
45
+
46
+ ```typescript
47
+ interface PermissionsService {
48
+ /** Query the permission policy for a surface and value. */
49
+ checkPermission(
50
+ surface: string,
51
+ value?: string,
52
+ agentName?: string,
53
+ ): PermissionCheckResult;
54
+
55
+ /** Query tool-level permission state for pre-filtering before session creation. */
56
+ getToolPermission(toolName: string, agentName?: string): PermissionState;
57
+
58
+ /**
59
+ * Register a custom preview formatter for a specific tool name.
60
+ * Returns a disposer that unregisters the formatter.
61
+ * Throws if a formatter is already registered for that tool name.
62
+ */
63
+ registerToolInputFormatter(
64
+ toolName: string,
65
+ formatter: (input: Record<string, unknown>) => string | undefined,
66
+ ): () => void;
67
+
68
+ /**
69
+ * Register a custom access-intent extractor for a specific tool name.
70
+ * Declares the filesystem path a tool accesses so the `path` and
71
+ * `external_directory` gates can see it. Returns a disposer; throws if an
72
+ * extractor is already registered for that tool name.
73
+ */
74
+ registerToolAccessExtractor(
75
+ toolName: string,
76
+ extractor: (input: Record<string, unknown>) => string | undefined,
77
+ ): () => void;
78
+ }
79
+ ```
80
+
81
+ #### `checkPermission`
82
+
83
+ | Parameter | Required | Description |
84
+ | ----------- | -------- | ---------------------------------------------------------------------------------------- |
85
+ | `surface` | Yes | Permission surface: `"bash"`, `"read"`, `"mcp"`, `"skill"`, `"external_directory"`, etc. |
86
+ | `value` | No | Value to evaluate (command, name, path); defaults to `""` |
87
+ | `agentName` | No | Agent name for per-agent policy resolution |
88
+
89
+ Returns `PermissionCheckResult` with fields `state`, `matchedPattern`, `source`, `origin`, etc.
90
+
91
+ For a path-shaped surface (`path`, `external_directory`, or a path-bearing tool — `read`/`write`/`edit`/`grep`/`find`/`ls`), the supplied `value` is matched against both the path as given and its canonical (symlink-resolved) form, at parity with the gates — so a query for a symlinked path matches a rule on its real target.
92
+
93
+ #### `getToolPermission`
94
+
95
+ Returns `"allow"` | `"deny"` | `"ask"` for a tool name without considering command-level rules.
96
+ Use this to pre-filter a tool list before creating a child session — it avoids calling `checkPermission` per tool and interpreting the full result.
97
+
98
+ ```typescript
99
+ const denied = tools.filter(
100
+ (t) => permissions.getToolPermission(t, agentName) === "deny",
101
+ );
102
+ ```
103
+
104
+ #### `registerToolInputFormatter`
105
+
106
+ Register a custom preview formatter for a specific tool name.
107
+ Permission ask-prompts call your formatter while building the prompt text, so you can show a human-readable summary of a tool call instead of the default truncated JSON.
108
+
109
+ ```typescript
110
+ registerToolInputFormatter(
111
+ toolName: string,
112
+ formatter: (input: Record<string, unknown>) => string | undefined,
113
+ ): () => void; // returns a disposer
114
+ ```
115
+
116
+ Registration rules:
117
+
118
+ - One formatter per tool name.
119
+ A second `register` for the same name throws — there is no silent override.
120
+ - The returned disposer unregisters the formatter.
121
+ It is identity-guarded, so a stale disposer cannot evict a later registration of the same name.
122
+
123
+ ##### Which tool name to key on
124
+
125
+ The `toolName` you register is matched against the **registered Pi tool name** the agent invoked — not against MCP server/tool pairs.
126
+
127
+ - For a tool your extension registers directly with Pi, use that tool's exact name (the same string Pi shows in `pi.getAllTools()`).
128
+ - For **MCP** calls, every server tool arrives as the single umbrella `"mcp"` tool, with the real target in `input.tool` (e.g. `"exa:search"`).
129
+ You therefore cannot register a formatter per `server:tool`.
130
+ The `"mcp"` name is already claimed by the built-in summarizer (below), and because duplicate registration throws, you cannot replace it.
131
+ If you need richer per-server MCP previews, open an issue — that requires a chained-formatter model this seam does not yet provide.
132
+ - `"bash"` never reaches your formatter: bash prompts take a dedicated branch that shows the command directly.
133
+
134
+ ##### What your formatter receives
135
+
136
+ The `input` argument is the raw tool-call input object exactly as the agent supplied it (the tool's arguments).
137
+ It is always a plain record; shapes by tool:
138
+
139
+ | Tool | `input` shape |
140
+ | ---------------------- | --------------------------------------------------------------------------------------- |
141
+ | `mcp` (umbrella) | `{ tool: "server:tool", server?, arguments?: object, … }` — summarize `input.arguments` |
142
+ | `read` | `{ path, offset?, limit? }` |
143
+ | `write` | `{ path, content }` |
144
+ | `edit` | `{ path, edits?: […] }` or `{ path, oldText, newText }` |
145
+ | `grep` / `find` / `ls` | `{ pattern?, glob?, path? }` |
146
+ | your own tool | whatever input schema your tool registered |
147
+
148
+ Treat every field as untrusted: the agent can emit malformed or partial input, so read defensively (type-check before use) rather than assuming a shape.
149
+
150
+ ##### What your return value does
151
+
152
+ The returned string is spliced into the middle of the prompt sentence:
153
+
154
+ ```text
155
+ Agent 'Explore' requested tool 'deploy' <your fragment>. Allow this call?
156
+ ```
157
+
158
+ Return a short grammatical fragment that reads naturally in that slot — e.g. `"with target staging (3 services)"` or `"runs 2 commands"`, not a full sentence and not raw JSON.
159
+
160
+ Return semantics:
161
+
162
+ - Return a **string** to use it verbatim as the preview (this also overrides the built-in preview for built-in tools like `read`/`edit`).
163
+ - Return **`undefined`** to decline — the prompt falls through to the built-in formatter for that tool, and finally to the truncated-JSON default.
164
+ Prefer `undefined` over `""` when you have nothing useful to add: an empty string short-circuits the fallthrough and suppresses the default preview entirely.
165
+
166
+ ##### Your formatter must not throw
167
+
168
+ The core does **not** wrap your formatter in a `try/catch`.
169
+ A thrown error propagates into prompt construction and can break the permission prompt — a denial-of-service on the gate.
170
+ Guard your own parsing and return `undefined` on anything unexpected.
171
+
172
+ ##### End-to-end wiring
173
+
174
+ Register during your extension's initialization and store the disposer for teardown:
175
+
176
+ ```typescript
177
+ export default function myExtension(pi: ExtensionAPI): void {
178
+ let disposeFormatter: (() => void) | undefined;
179
+
180
+ void (async () => {
181
+ try {
182
+ const { getPermissionsService } = await import(
183
+ "@gotgenes/pi-permission-system"
184
+ );
185
+ const permissions = getPermissionsService();
186
+ disposeFormatter = permissions?.registerToolInputFormatter(
187
+ "deploy", // a tool THIS extension registers with Pi
188
+ (input) => {
189
+ const target =
190
+ typeof input.target === "string" ? input.target : undefined;
191
+ const services = Array.isArray(input.services)
192
+ ? input.services.length
193
+ : undefined;
194
+ if (!target) return undefined; // decline → default preview
195
+ return services !== undefined
196
+ ? `with target ${target} (${services} services)`
197
+ : `with target ${target}`;
198
+ },
199
+ );
200
+ } catch {
201
+ // permission-system not installed — nothing to register
202
+ }
203
+ })();
204
+
205
+ pi.on("session_shutdown", () => {
206
+ disposeFormatter?.();
207
+ disposeFormatter = undefined;
208
+ });
209
+ }
210
+ ```
211
+
212
+ Reload note: on `/reload`, the permission-system publishes a fresh service backed by a new registry, so previous registrations are dropped.
213
+ Re-register on every initialization (as above) rather than once globally; the disposer is for explicit teardown within a single load.
214
+
215
+ ##### Recommended practices
216
+
217
+ - Keep previews short — they appear inline in a yes/no prompt, and the result is truncated by the configured preview length anyway.
218
+ - Never surface secrets (tokens, keys, full request bodies) in a preview; summarize counts and identifiers instead.
219
+ - Parse defensively and return `undefined` on malformed input — never throw.
220
+ - Return a grammatical fragment, not raw JSON or a full sentence.
221
+ - Register idempotently on each extension load; dispose on `session_shutdown`.
222
+
223
+ ##### Built-in MCP summarizer
224
+
225
+ A built-in formatter is registered for the `"mcp"` tool at startup (through this same public API).
226
+ It renders a compact `with key: value, …` summary of the call's `arguments` and returns `undefined` when there are no arguments, leaving the MCP target prompt unchanged.
227
+ This is the reference implementation for the seam — see `src/builtin-tool-input-formatters.ts`.
228
+
229
+ #### `registerToolAccessExtractor`
230
+
231
+ Declare the filesystem path a tool will access so the cross-cutting `path` and `external_directory` gates can evaluate it.
232
+
233
+ ```typescript
234
+ registerToolAccessExtractor(
235
+ toolName: string,
236
+ extractor: (input: Record<string, unknown>) => string | undefined,
237
+ ): () => void; // returns a disposer
238
+ ```
239
+
240
+ You usually do **not** need this.
241
+ Path gating is on by default for every tool whose input follows the convention:
242
+
243
+ - Built-in file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`) and any tool exposing `input.path` are extracted automatically.
244
+ - MCP calls are extracted from `input.arguments.path`.
245
+ - `bash` is never extracted here — it has its own token-based path gates.
246
+
247
+ Register an extractor only when a tool carries its path under a **non-standard key** (e.g. `input.target` or `input.file`).
248
+ Return the path string, or `undefined` to decline.
249
+
250
+ ```typescript
251
+ const dispose = permissions.registerToolAccessExtractor("ffgrep", (input) =>
252
+ typeof input.target === "string" ? input.target : undefined,
253
+ );
254
+ ```
255
+
256
+ Registration rules mirror `registerToolInputFormatter`: one extractor per tool name (a second `register` for the same name throws), and the returned disposer is identity-guarded.
257
+ The extractor must not throw — guard your parsing and return `undefined` on anything unexpected.
258
+
259
+ #### Subagent session registration
260
+
261
+ In-process subagent registration is event-driven.
262
+ `@gotgenes/pi-subagents` emits `subagents:child:session-created` before `bindExtensions()` and `subagents:child:disposed` in the run's `finally`; the permission system subscribes automatically — no service call from the spawner is required.
263
+ See [Subagent Integration](subagent-integration.md) for details.
264
+
265
+ ### Reload Safety
266
+
267
+ During `/reload`, all extensions re-initialize.
268
+ The permission-system re-publishes a fresh service at `session_start`; teardown is identity-scoped, so a superseded generation's shutdown only clears the slot when it still owns it and cannot wipe the new service.
269
+ Consumers that re-initialize during reload naturally get the new instance.
270
+
271
+ Best practice: call `getPermissionsService()` per use rather than caching the reference.
272
+
273
+ ### Graceful Degradation
274
+
275
+ `getPermissionsService()` returns `undefined` when the permission-system extension has not loaded (or has been unloaded).
276
+ The `import()` throws if the package is not installed.
277
+ Wrap both in `try/catch` + `if` guard as shown in the Quick Start example.
278
+
279
+ ---
280
+
281
+ ## Event Bus
282
+
283
+ The extension also 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.
284
+
285
+ ## Stability Guarantee
286
+
287
+ Fields may be added to any payload, but existing fields will not be removed or renamed without a semver-major version bump.
288
+ The broadcast contract is defined by the published TypeScript types plus package semver — broadcast payloads (`permissions:ready`, `permissions:ui_prompt`, `permissions:decision`) carry no `protocolVersion`.
289
+ The `PERMISSIONS_PROTOCOL_VERSION` constant is exported from `src/permission-events.ts` and embedded only in the RPC reply envelope, where per-call request/reply negotiation is load-bearing.
290
+ Consumers should read broadcast payloads defensively (field-presence checks) rather than version-gating — that is robust to any shape skew between independently-versioned sibling extensions.
291
+
292
+ All three broadcasts are best-effort: a throwing listener cannot block permission handling, session startup, or gate resolution.
293
+
294
+ ## Channel Reference
295
+
296
+ | Channel | Direction | When | Payload type |
297
+ | ------------------------------------------ | --------- | --------------------------------- | ------------------------------------------------- |
298
+ | `permissions:ready` | Broadcast | At `session_start`, after publish | `PermissionsReadyEvent` |
299
+ | `permissions:ui_prompt` | Broadcast | Before active UI prompt | `PermissionUiPromptEvent` |
300
+ | `permissions:decision` | Broadcast | After every gate resolution | `PermissionDecisionEvent` |
301
+ | `permissions:rpc:check` | Request | On-demand | `PermissionsCheckRequest` |
302
+ | `permissions:rpc:check:reply:<requestId>` | Reply | After each check request | `PermissionsRpcReply<PermissionsCheckReplyData>` |
303
+ | `permissions:rpc:prompt` | Request | On-demand | `PermissionsPromptRequest` |
304
+ | `permissions:rpc:prompt:reply:<requestId>` | Reply | After prompt is resolved | `PermissionsRpcReply<PermissionsPromptReplyData>` |
305
+
306
+ ---
307
+
308
+ ## UI Prompt Broadcasts
309
+
310
+ The permission system emits `permissions:ui_prompt` immediately before it invokes the active user-facing permission UI.
311
+ This event is for integrations such as notification extensions that should alert only when the user needs to respond to a permission prompt.
312
+ It is not a generic "permission request entered waiting state" event, and it does not imply the prompt will be approved.
313
+ Policy decisions that resolve without an active UI prompt, such as `policy_allow`, `policy_deny`, `session_approved`, `infrastructure_auto_allowed`, or `auto_approved`, do not emit this event.
314
+ Non-UI child sessions also do not emit this event when they create a forwarded permission request; the parent UI session emits it immediately before showing the forwarded permission dialog.
315
+ Forwarded prompts are not degraded: the parent emits the child's original `source` and the same `surface`/`value` display projection, plus a populated `forwarding` context identifying the requesting subagent.
316
+
317
+ The payload is lean by design — `surface`/`value` are the normalized display projection a notification consumer reads, not a mirror of the internal review log.
318
+ Read defensively rather than version-gating: broadcast payloads carry no `protocolVersion`.
319
+
320
+ ```typescript
321
+ import type { PermissionUiPromptEvent } from "@gotgenes/pi-permission-system";
322
+
323
+ pi.events.on("permissions:ui_prompt", (raw) => {
324
+ const event = raw as PermissionUiPromptEvent;
325
+ // Defensive read: tolerate any shape skew between sibling extensions.
326
+ if (typeof event.value !== "string" && typeof event.message !== "string") {
327
+ return;
328
+ }
329
+ notify(event.surface, event.value, event.message);
330
+ // e.g. "bash" "git push" "Allow git push?"
331
+ });
332
+ ```
333
+
334
+ ### Payload Fields
335
+
336
+ | Field | Type | Description |
337
+ | ------------ | -------------------------------- | -------------------------------------------------------------------------------- |
338
+ | `requestId` | `string` | Unique ID for the permission request being prompted |
339
+ | `source` | `PermissionUiPromptSource` | Prompt origin: `"tool_call"`, `"skill_input"`, `"skill_read"`, or `"rpc_prompt"` |
340
+ | `surface` | `string \| null` | Normalized display surface (e.g. `"bash"`, `"skill"`), when known |
341
+ | `value` | `string \| null` | Normalized display value (command, path, skill name, etc.), when known |
342
+ | `agentName` | `string \| null` | Active/requesting agent name, when known |
343
+ | `message` | `string` | Message displayed in the permission prompt |
344
+ | `forwarding` | `ForwardedPromptContext \| null` | Forwarding context, or `null` for a direct prompt |
345
+
346
+ Forwarding is orthogonal to origin: a forwarded subagent prompt keeps its original `source` and is identified by a non-null `forwarding` field, not by a dedicated source value.
347
+
348
+ #### `ForwardedPromptContext`
349
+
350
+ Present only when the prompt was forwarded from a non-UI subagent.
351
+
352
+ | Field | Type | Description |
353
+ | -------------------- | ---------------- | ---------------------------------------------- |
354
+ | `requesterAgentName` | `string \| null` | Requesting subagent's display name, when known |
355
+ | `requesterSessionId` | `string \| null` | Requesting subagent's session id, when known |
356
+
357
+ The `surface`/`value` pair is a deliberate display projection that replaces the redundant per-source fields (`command`/`path`/`target`/`skillName`/`toolName`/`toolCallId`/`toolInputPreview`/`sessionLabel`) from earlier drafts — none of which the notification use case reads.
358
+ The stability guarantee is additive, so any can be reintroduced in a later minor when a concrete consumer needs them.
359
+
360
+ ---
361
+
362
+ ## Decision Broadcasts
363
+
364
+ Every permission gate resolution emits a `permissions:decision` event, regardless of outcome.
365
+ This is useful for dashboards, telemetry, or audit overlays.
366
+
367
+ ```typescript
368
+ pi.events.on("permissions:decision", (raw) => {
369
+ const event = raw as import("@gotgenes/pi-permission-system").PermissionDecisionEvent;
370
+ console.log(event.surface, event.result, event.resolution);
371
+ // e.g. "bash" "allow" "user_approved_for_session"
372
+ });
373
+ ```
374
+
375
+ ### Payload Fields
376
+
377
+ | Field | Type | Description |
378
+ | ---------------- | ------------------- | ----------------------------------------------------------------------------------------- |
379
+ | `surface` | `string` | Permission surface (`"bash"`, `"read"`, `"mcp"`, `"skill"`, `"external_directory"`, etc.) |
380
+ | `value` | `string` | Value evaluated (command, tool name, skill name, path) |
381
+ | `result` | `"allow" \| "deny"` | Final outcome |
382
+ | `resolution` | `string` | How the outcome was reached (see table below) |
383
+ | `origin` | `string \| null` | Config scope that contributed the winning rule |
384
+ | `agentName` | `string \| null` | Active agent name when known |
385
+ | `matchedPattern` | `string \| null` | Pattern from the winning rule |
386
+
387
+ ### Resolution Values
388
+
389
+ | Value | Meaning |
390
+ | ----------------------------- | -------------------------------------------------------------------- |
391
+ | `policy_allow` | Config rule said allow — no prompt shown |
392
+ | `policy_deny` | Config rule said deny — blocked immediately |
393
+ | `session_approved` | Covered by a session-level approval from earlier in the same session |
394
+ | `infrastructure_auto_allowed` | Read of a Pi infrastructure path — auto-allowed |
395
+ | `user_approved` | User approved once via dialog |
396
+ | `user_approved_for_session` | User approved for the rest of the session |
397
+ | `user_denied` | User denied via dialog |
398
+ | `auto_approved` | Yolo mode — approved automatically without dialog |
399
+ | `confirmation_unavailable` | State was `ask` but no UI was available — blocked |
400
+
401
+ ---
402
+
403
+ ## Policy Query RPC (deprecated)
404
+
405
+ > **Deprecated**: prefer the [Service Accessor](#service-accessor) above.
406
+ > The event-bus RPC remains available as a zero-dependency fallback.
407
+
408
+ Other extensions can evaluate the current permission policy without importing this package.
409
+ The call is synchronous-style: emit a request, listen on a scoped reply channel.
410
+
411
+ ```typescript
412
+ const requestId = crypto.randomUUID();
413
+
414
+ // Listen for the reply first
415
+ const unsub = pi.events.on(
416
+ `permissions:rpc:check:reply:${requestId}`,
417
+ (raw) => {
418
+ unsub();
419
+ const reply = raw as import("@gotgenes/pi-permission-system").PermissionsRpcReply<
420
+ import("@gotgenes/pi-permission-system").PermissionsCheckReplyData
421
+ >;
422
+ if (reply.success) {
423
+ console.log(reply.data?.result); // "allow" | "deny" | "ask"
424
+ }
425
+ },
426
+ );
427
+
428
+ // Then emit the request
429
+ pi.events.emit("permissions:rpc:check", {
430
+ requestId,
431
+ surface: "bash",
432
+ value: "git push",
433
+ agentName: "Worker", // optional
434
+ });
435
+ ```
436
+
437
+ If the extension is not loaded, no reply arrives.
438
+ Callers should implement a timeout and treat no-reply as `deny` (graceful degradation).
439
+
440
+ ### Request Fields
441
+
442
+ | Field | Required | Description |
443
+ | ----------- | -------- | ---------------------------------------------------------- |
444
+ | `requestId` | Yes | Unique string; scopes the reply channel |
445
+ | `surface` | Yes | Permission surface to evaluate |
446
+ | `value` | No | Value to evaluate (command, name, path); defaults to `"*"` |
447
+ | `agentName` | No | Agent name for per-agent policy resolution |
448
+
449
+ As with the `checkPermission` service method, a path-shaped surface (`path`, `external_directory`, or a path-bearing tool) matches the `value` against both the path as given and its canonical (symlink-resolved) form.
450
+
451
+ ### Reply Data Fields (`PermissionsCheckReplyData`)
452
+
453
+ | Field | Type | Description |
454
+ | ---------------- | ---------------------------- | ------------------------------------------------ |
455
+ | `result` | `"allow" \| "deny" \| "ask"` | Policy decision (including active session rules) |
456
+ | `matchedPattern` | `string \| null` | Matched rule pattern |
457
+ | `origin` | `string \| null` | Config scope of the winning rule |
458
+
459
+ ---
460
+
461
+ ## Prompt Forwarding RPC
462
+
463
+ In-process child sessions (e.g. tintinweb/pi-subagents running via `createAgentSession()`) cannot use file-based permission forwarding because no child process is spawned.
464
+ They can instead forward permission prompts to the parent session's UI via this RPC.
465
+
466
+ ```typescript
467
+ const requestId = crypto.randomUUID();
468
+
469
+ const unsub = pi.events.on(
470
+ `permissions:rpc:prompt:reply:${requestId}`,
471
+ (raw) => {
472
+ unsub();
473
+ const reply = raw as import("@gotgenes/pi-permission-system").PermissionsRpcReply<
474
+ import("@gotgenes/pi-permission-system").PermissionsPromptReplyData
475
+ >;
476
+ if (reply.success && reply.data?.approved) {
477
+ // proceed
478
+ } else {
479
+ // deny — either user denied or no UI was available (error: "no_ui")
480
+ }
481
+ },
482
+ );
483
+
484
+ pi.events.emit("permissions:rpc:prompt", {
485
+ requestId,
486
+ surface: "bash",
487
+ value: "rm -rf /tmp/build",
488
+ message: "Allow rm -rf /tmp/build?",
489
+ agentName: "Explore", // optional
490
+ sessionLabel: "Allow rm *", // optional — label for the "for this session" option
491
+ });
492
+ ```
493
+
494
+ The handler replies with `{ success: false, error: "no_ui" }` when no interactive session is available.
495
+
496
+ ### Successful Reply Fields
497
+
498
+ | Field | Type | Description |
499
+ | -------------- | ------------------- | ----------------------------------------------------------------------------- |
500
+ | `approved` | `boolean` | Whether the user approved |
501
+ | `state` | `string` | `"approved"`, `"approved_for_session"`, `"denied"`, or `"denied_with_reason"` |
502
+ | `denialReason` | `string` (optional) | User-provided denial reason |
503
+
504
+ ---
505
+
506
+ ## Ready Event
507
+
508
+ The extension emits `permissions:ready` at `session_start`, right after the service is published — so a consumer reacting to it can immediately resolve `getPermissionsService()`.
509
+ It fires once per `session_start` (including `/reload`).
510
+ 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.
511
+
512
+ The payload is intentionally empty (`Record<string, never>`): the channel is a pure readiness signal.
513
+ It carries no `protocolVersion` — version negotiation lives in the RPC reply envelope, and the broadcast contract is defined by the published types plus package semver.
514
+
515
+ ```typescript
516
+ pi.events.on("permissions:ready", () => {
517
+ void (async () => {
518
+ const { getPermissionsService } = await import(
519
+ "@gotgenes/pi-permission-system"
520
+ );
521
+ const permissions = getPermissionsService();
522
+ // The service is published just before this fires — resolve it now.
523
+ })();
524
+ });
525
+ ```