26 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 29 | Re-add permission-request event channel with a proper public contract |
Permission event channel with decision broadcast and RPC
Problem Statement
Issue #20 deleted the undocumented pi-permission-system:permission-request event channel because it had no subscribers, no documentation, no tests, and no public type contract.
The concept is useful — three major subagent extensions (nicobailon/pi-subagents, tintinweb/pi-subagents, HazAT/pi-interactive-subagents) need to interact with the permission system at runtime, and pi.events is the only cross-extension communication channel Pi provides.
This plan re-introduces the channel with a proper public contract: exported types, documentation, payload-shape tests, protocol versioning, and three distinct surfaces.
Goals
- Emit
permissions:decisionevents for every permission gate resolution so external consumers can observe decisions. - Expose a
permissions:rpc:checkrequest/reply RPC so other extensions can query the permission policy without importing this package. - Expose a
permissions:rpc:promptrequest/reply RPC so in-process child sessions (tintinweb) can forward permission prompts to the parent session's UI. - Emit
permissions:readyon extension load so consumers can detect the extension's presence without polling. - Export TypeScript types from a dedicated
src/permission-events.tsmodule for compile-time type safety. - Add payload-shape regression tests for every emitted event and RPC reply.
- Document channel names, payload fields, protocol version, stability guarantees, and worked examples in
README.md.
Non-Goals
- Building a general-purpose cross-extension RPC framework — scoped to permission events only.
- Replacing the file-based permission forwarding for CLI-spawned subagents (#96) — the event bus RPC is specifically for in-process subagents.
- Proposing changes to Pi's extension API (badlogic/pi-mono#4207 tracks
registerService()/getService()). - Adopting the event API in subagent extensions (#98) — this plan builds the prerequisite API.
- Adding authentication or caller validation to RPC handlers —
pi.eventsis shared within a single Node.js process; any loaded extension can emit.
Background
Dependency status
| Issue | Description | Status |
|---|---|---|
| #20 | Deleted the original undocumented channel | ✅ Closed |
| #96 | Env var broadening for CLI-spawned subagents | ✅ Closed |
| #97 | Coexistence documentation | ✅ Closed |
| #98 | Adoption by subagent maintainers | Open — depends on this plan |
All hard prerequisites are resolved. This plan unblocks #98.
Pi SDK event bus
The ExtensionAPI provides events: EventBus with an untyped interface:
interface EventBus {
emit(channel: string, data: unknown): void;
on(channel: string, handler: (data: unknown) => void): () => void;
}
This is the only cross-extension communication mechanism Pi provides. tintinweb/pi-subagents already built a working RPC over this bus with protocol versioning, request/reply envelopes, and scoped reply channels — a proven pattern in the ecosystem.
Affected permission surfaces
This change touches the event emission layer — it does not change any allow/deny/ask decision logic.
All six permission surfaces (tools, bash, mcp, skills, special, external_directory) emit decision broadcasts.
The RPC check endpoint covers all surfaces.
The RPC prompt endpoint handles the ask state for any surface.
Existing forwarding model
File-based forwarding (src/forwarded-permissions/) handles CLI-spawned subagents (different processes, different event buses).
The event bus RPC handles in-process subagents (same process, shared event bus) where file-based forwarding cannot work because no child process exists.
Both paths coexist — they serve different transport needs.
Design Overview
Channel taxonomy
| Channel | Direction | Purpose |
|---|---|---|
permissions:ready |
Broadcast | Emitted once on extension load; consumers detect presence |
permissions:decision |
Broadcast | Emitted after every permission gate resolution |
permissions:rpc:check |
Request | Query the permission policy (no prompting) |
permissions:rpc:check:reply:<requestId> |
Reply | Response to a check request |
permissions:rpc:prompt |
Request | Forward a permission prompt to the parent's UI |
permissions:rpc:prompt:reply:<requestId> |
Reply | Response to a prompt request |
Envelope shapes
Following tintinweb's convention: success envelope { success: true, data?: T }, error envelope { success: false, error: string }.
All RPC requests include requestId: string for reply channel scoping.
All RPC replies include protocolVersion: number for forward compatibility.
/** RPC protocol version — bumped on breaking envelope or method changes. */
export const PERMISSIONS_PROTOCOL_VERSION = 1;
/** RPC reply envelope. */
export type PermissionsRpcReply<T = void> =
| { success: true; protocolVersion: number; data?: T }
| { success: false; protocolVersion: number; error: string };
Surface 1: Decision broadcast
Emitted after every permission gate outcome in the handler layer — not inside applyPermissionGate() itself, because the gate lacks the full context (surface name, command, agent name, origin).
export interface PermissionDecisionEvent {
/** Permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
surface: string;
/** The value that was evaluated (command, tool name, skill name, path). */
value: string;
/** Final decision. */
result: "allow" | "deny";
/** How the decision was reached. */
resolution:
| "policy_allow"
| "policy_deny"
| "session_approved"
| "infrastructure_auto_allowed"
| "user_approved"
| "user_approved_for_session"
| "user_denied"
| "auto_approved"
| "confirmation_unavailable";
/** Which config scope contributed the winning rule (when available). */
origin: string | null;
/** Agent name (when known). */
agentName: string | null;
/** Matched pattern from the winning rule (when available). */
matchedPattern: string | null;
}
Emission points in handlers
Each handler site calls a shared emitDecisionEvent(events, payload) helper after the gate resolves.
The helper is thin — it constructs the channel name and calls events.emit().
| Handler | Gate/check | Resolution mapped |
|---|---|---|
tool-call.ts — session-hit fast path |
checkPermission returns source: "session" |
session_approved |
tool-call.ts — infrastructure read bypass |
isPiInfrastructureRead returns true |
infrastructure_auto_allowed |
tool-call.ts — skill-read gate |
applyPermissionGate result |
policy_allow, policy_deny, user_*, confirmation_unavailable |
tool-call.ts — external-directory gate |
applyPermissionGate result |
Same set |
tool-call.ts — bash external-directory gate |
applyPermissionGate result |
Same set |
tool-call.ts — normal tool gate |
applyPermissionGate result |
Same set |
input.ts — skill input gate |
applyPermissionGate result |
Same set |
Mapping gate outcomes to resolution
The applyPermissionGate return type is { action: "allow" | "block" }.
The resolution is derived from the combination of:
- The input
state("allow", "deny", "ask") - Whether the prompt was shown (canConfirm)
- The user's decision (approved, approved_for_session, denied, denied_with_reason)
To capture this without modifying the gate's return type, the handler wraps the gate call with pre/post tracking.
The approach: check the state before the gate call, check the action after, and reconstruct the resolution:
state === "allow"→policy_allowstate === "deny"→policy_denystate === "ask"andaction === "allow"→user_approvedoruser_approved_for_session(distinguished bysessionApprovalin the gate result)state === "ask"andaction === "block"→user_deniedorconfirmation_unavailable(distinguished bycanConfirm)
Auto-approved (yolo mode) is handled inside PermissionPrompter before the gate is called.
To capture it, the prompter's prompt() method emits the decision event directly for the auto-approve path, or we track the auto-approve outcome via the decision callback.
Better approach: add an optional onDecision callback to the PermissionPrompter that fires for auto-approved decisions, so the handler can distinguish yolo auto-approve from user approval.
Surface 2: Policy query RPC
The handler listens on permissions:rpc:check, evaluates the policy, and replies on permissions:rpc:check:reply:<requestId>.
export interface PermissionsCheckRequest {
requestId: string;
surface: string;
/** The value to evaluate: command string, tool name, skill name, or path. */
value?: string;
/** Optional agent name for per-agent policy resolution. */
agentName?: string;
}
export interface PermissionsCheckReplyData {
result: "allow" | "deny" | "ask";
matchedPattern: string | null;
origin: string | null;
}
Implementation: call permissionManager.checkPermission() with the provided surface and value, including current session rules.
The handler constructs a synthetic input object from surface + value that matches what normalizeInput() expects.
Surface 3: Prompt forwarding RPC
The handler listens on permissions:rpc:prompt, shows a UI dialog, and replies on permissions:rpc:prompt:reply:<requestId>.
export interface PermissionsPromptRequest {
requestId: string;
surface: string;
/** Value being evaluated (shown in the dialog). */
value: string;
/** Optional agent name for display. */
agentName?: string;
/** Message to display in the permission dialog. */
message: string;
/** Optional label for the "for this session" option. */
sessionLabel?: string;
}
export interface PermissionsPromptReplyData {
approved: boolean;
/** Detailed state: "approved", "approved_for_session", "denied", "denied_with_reason". */
state: string;
denialReason?: string;
}
Guard: only respond when UI is available
The handler checks runtime.runtimeContext?.hasUI before attempting the dialog.
If no UI context exists, it replies with { success: false, error: "no_ui" }.
The caller treats error replies or timeouts as denial (graceful degradation).
In-process concurrency
Multiple child sessions may request prompts simultaneously. Since the Pi UI is sequential (one dialog at a time), concurrent RPC prompt requests are serialized by the UI's own dialog queue. No explicit concurrency control is needed in the handler.
Ready event
export interface PermissionsReadyEvent {
protocolVersion: number;
}
Emitted once in piPermissionSystemExtension() after RPC handlers are registered.
Consumers listen for permissions:ready to detect the extension's presence and protocol version.
Versioning policy
All exported types carry the PERMISSIONS_PROTOCOL_VERSION constant.
RPC replies include protocolVersion in the envelope.
Stability guarantee: fields may be added, but existing fields will not be removed or renamed without a major version bump (semver-major change).
Integration points
The pi.events bus is accessed via the ExtensionAPI parameter in piPermissionSystemExtension().
It is passed to:
- An
emitDecisionEvent()helper (called from handlers) registerPermissionRpcHandlers()(called once during setup)- A
permissions:readyemit (called once during setup)
The HandlerDeps interface gains an events field (the EventBus reference) so handlers can call emitDecisionEvent().
Module-Level Changes
| File | Action | Detail |
|---|---|---|
src/permission-events.ts |
new | Channel name constants, protocol version, all event/request/reply TypeScript types, emitDecisionEvent() helper, emitReadyEvent() helper |
src/permission-event-rpc.ts |
new | registerPermissionRpcHandlers(events, deps) — wires permissions:rpc:check and permissions:rpc:prompt handlers; returns unsubscribe functions |
src/index.ts |
changed | Pass pi.events to RPC registration and handler deps; emit permissions:ready; store unsubscribe handles for cleanup in session_shutdown |
src/handlers/types.ts |
changed | Add events: EventBus (or emitDecision: (event) => void) to HandlerDeps |
src/handlers/tool-call.ts |
changed | Call emitDecisionEvent() after each gate resolution and session/infrastructure fast path |
src/handlers/input.ts |
changed | Call emitDecisionEvent() after skill input gate resolution |
src/handlers/before-agent-start.ts |
unchanged | Tool filtering is a pre-start phase, not an individual decision event (no emission here) |
src/permission-gate.ts |
unchanged | Gate remains a pure decision function; emission stays at the handler layer |
src/permission-prompter.ts |
changed | Add optional onAutoApprove callback or return metadata so handlers can distinguish yolo auto-approve from user approval for the decision event |
tests/permission-events.test.ts |
new | Payload-shape assertions for PermissionDecisionEvent, PermissionsReadyEvent, all RPC request/reply shapes |
tests/permission-event-rpc.test.ts |
new | RPC handler tests: check returns correct result, prompt shows dialog and returns decision, error replies for missing UI, unknown surface |
tests/handlers/tool-call-events.test.ts |
new or merged into existing | Verify permissions:decision emitted with correct payload for allow, deny, ask→approved, session-approved, infrastructure-bypass paths |
tests/handlers/input-events.test.ts |
new or merged into existing | Verify permissions:decision emitted for skill input gate |
README.md |
changed | Add "Event API" section: channel names, payload fields, RPC protocol, stability guarantees, worked examples |
docs/architecture/target-architecture.md |
changed | Add src/permission-events.ts and src/permission-event-rpc.ts to module structure; add event bus section to architecture overview |
TDD Order
Step 1: Types, constants, and helper stubs
Define all types and channel constants in src/permission-events.ts.
Export the emitDecisionEvent() and emitReadyEvent() helpers (thin wrappers around events.emit()).
Export PermissionsRpcReply, PERMISSIONS_PROTOCOL_VERSION, all event/request/reply interfaces.
- Red:
tests/permission-events.test.ts— assert module exports expected constants (PERMISSIONS_PROTOCOL_VERSION === 1, channel name strings), assertemitDecisionEventcallsevents.emitwith the channel name and payload, assertemitReadyEventemits the correct shape. - Green: Implement
src/permission-events.tswith types, constants, and emit helpers. - Commit:
feat: add permission event types and emit helpers (#29)
Step 2: Ready event
Wire emitReadyEvent() in src/index.ts after extension setup.
- Red:
tests/permission-events.test.ts(or integration test) — assert thatpermissions:readyis emitted with{ protocolVersion: 1 }when extension loads. (May test via a mockpi.eventsin an integration harness or unit-test the helper directly.) - Green: Add
emitReadyEvent(pi.events)call inpiPermissionSystemExtension(). - Commit:
feat: emit permissions:ready on extension load (#29)
Step 3: Policy query RPC handler
Implement registerPermissionRpcHandlers() in src/permission-event-rpc.ts — starting with the permissions:rpc:check handler.
- Red:
tests/permission-event-rpc.test.ts— mock event bus and permission manager; emit apermissions:rpc:checkrequest; assert the handler replies onpermissions:rpc:check:reply:<requestId>with{ success: true, protocolVersion: 1, data: { result: "allow", ... } }. Test deny, ask, unknown surface, and missingrequestIdcases. - Green: Implement the check handler in
src/permission-event-rpc.ts. Wire it insrc/index.ts. - Commit:
feat: add permissions:rpc:check policy query RPC (#29)
Step 4: Prompt forwarding RPC handler
Add the permissions:rpc:prompt handler to registerPermissionRpcHandlers().
- Red:
tests/permission-event-rpc.test.ts— emit apermissions:rpc:promptrequest; assert the handler calls the UI dialog function with the message; assert the reply contains the approval decision. Test the no-UI guard (reply withsuccess: false, error: "no_ui"). Test user-denied path. - Green: Implement the prompt handler.
It checks
runtime.runtimeContext?.hasUI, callsrequestPermissionDecisionFromUi, and emits the reply. - Commit:
feat: add permissions:rpc:prompt forwarding RPC (#29)
Step 5: Wire RPC cleanup on session shutdown
Store unsubscribe handles from registerPermissionRpcHandlers() and call them in handleSessionShutdown().
- Red:
tests/permission-event-rpc.test.ts— assert unsubscribe functions are returned and callable. - Green: Store handles in
ExtensionRuntimeorHandlerDeps; call in shutdown. - Commit:
feat: clean up RPC handlers on session shutdown (#29)
Step 6: Decision broadcast in tool-call handler
Add events (or emitDecision) to HandlerDeps.
Emit permissions:decision after each gate resolution in handleToolCall.
- Red:
tests/handlers/tool-call-events.test.ts— mockdeps.events.emit; exercisehandleToolCallwith allow, deny, ask→approved, session-approved, and infrastructure-auto-allowed scenarios; assert each emitspermissions:decisionwith the correctresolutionandsurface. - Green: Add
eventstoHandlerDepsinsrc/handlers/types.ts. AddemitDecisionEvent()calls insrc/handlers/tool-call.tsat each decision point. Updatesrc/index.tsto passpi.eventsin deps. - Red/Green: Update existing tool-call handler tests that construct
HandlerDepsto include the neweventsfield (mock{ emit: vi.fn(), on: vi.fn() }). This is a pre-requisite for the existing test suite to pass after the type change. - Commit:
feat: emit permission decision events from tool-call handler (#29)
Step 7: Decision broadcast in input handler
Emit permissions:decision after the skill input gate in handleInput.
- Red:
tests/handlers/input-events.test.ts— mockdeps.events.emit; exercise skill input allow, deny, ask paths; assert emission. - Green: Add
emitDecisionEvent()calls insrc/handlers/input.ts. - Commit:
feat: emit permission decision events from input handler (#29)
Step 8: Auto-approve resolution tracking
Ensure yolo-mode auto-approved decisions emit with resolution: "auto_approved" rather than "user_approved".
The PermissionPrompter.prompt() returns { approved: true, state: "approved" } for both user approval and yolo auto-approve — they are indistinguishable at the handler level.
- Red: Test that when yolo mode is enabled, the emitted decision has
resolution: "auto_approved". - Green: Either add a distinguishing field to
PermissionPromptDecision(e.g.autoApproved: true) or have the prompter accept anonAutoApprovecallback. The handler sets a local flag before calling the gate and checks it when constructing the emission payload. - Commit:
feat: distinguish auto-approved from user-approved in decision events (#29)
Step 9: Documentation
Add "Event API" section to README.md with channel names, payload fields, protocol version, stability guarantees, worked examples for decision broadcast, check RPC, and prompt RPC.
Update docs/architecture/target-architecture.md module list.
- Commit:
docs: document permission event API and RPC protocol (#29)
Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Could this silently weaken a permission? | No. Event emission is fire-and-forget; it does not alter any allow/deny/ask decision path. The gate logic is unchanged. |
| RPC prompt handler could be exploited by a malicious extension to show unwanted dialogs | pi.events is process-scoped — any loaded extension already has full access to the UI. The prompt handler adds no new attack surface beyond what pi.events.emit("input", ...) already provides. The handler also requires hasUI to respond. |
| Multiple extension instances register duplicate RPC handlers on the same event bus | In-process subagent child sessions (tintinweb) do not reload extensions — only the parent instance is active. CLI-spawned subagents run in separate processes with separate event buses. The plan adds a hasUI guard so even if duplicates exist, only the instance with UI responds. |
| Decision event payload bloat slows down the event bus | Payloads are small (<500 bytes). The event bus is synchronous in-process emit() — no serialization overhead. |
| Breaking change to channel names after adoption | Channel names are constants exported from src/permission-events.ts. Versioning policy: no renames without semver-major. protocolVersion in RPC replies enables forward-compatible negotiation. |
Existing tests break when HandlerDeps gains an events field |
Step 6 explicitly folds in the type change and mock updates for existing test files before adding emission logic. |
Open Questions
- Should
before_agent_starttool-filtering decisions emit events? Tool filtering is a bulk pre-start phase (deny tools hidden before the agent runs). Emitting for each filtered tool could be noisy. Deferred — the handler can opt in later without API changes. - Should the prompt RPC handler write to the permission review log?
Currently, the review log captures all UI dialog outcomes.
The RPC prompt handler should likely log too, but the source would be
"rpc_prompt"rather than"tool_call". Deferred to implementation. - Should the check RPC normalize the
valueinput the same way handlers do? For example, bash commands go throughnormalizeInput()which extracts the command string. Exposing rawcheckPermission()without normalization may surprise callers. Deferred — start with raw passthrough and document the limitation. - Should we add a
permissions:rpc:pingchannel for health checks? tintinweb usessubagents:rpc:ping. Thepermissions:readyevent serves a similar purpose, but a synchronous ping RPC could be useful for late-arriving consumers. Low cost to add — can be folded into step 3 if desired.