@gotgenes/pi-permission-system 5.2.0 → 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.
@@ -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
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Permission event channel — public contract.
3
+ *
4
+ * Exports channel name constants, protocol version, TypeScript types for all
5
+ * emitted events and RPC envelopes, and thin emit helpers.
6
+ *
7
+ * Stability guarantee: fields may be added, but existing fields will not be
8
+ * removed or renamed without a semver-major version bump.
9
+ */
10
+
11
+ /** Minimal event bus interface required by the emit helpers and RPC handlers. */
12
+ export interface PermissionEventBus {
13
+ emit(channel: string, data: unknown): void;
14
+ on(channel: string, handler: (data: unknown) => void): () => void;
15
+ }
16
+
17
+ // ── Protocol version ───────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * RPC protocol version.
21
+ * Bumped when the envelope shape or method contracts change in a breaking way.
22
+ */
23
+ export const PERMISSIONS_PROTOCOL_VERSION = 1;
24
+
25
+ // ── Channel name constants ─────────────────────────────────────────────────
26
+
27
+ /** Emitted once on extension load. */
28
+ export const PERMISSIONS_READY_CHANNEL = "permissions:ready";
29
+
30
+ /** Emitted after every permission gate resolution. */
31
+ export const PERMISSIONS_DECISION_CHANNEL = "permissions:decision";
32
+
33
+ /** RPC request channel — query the permission policy (no prompting). */
34
+ export const PERMISSIONS_RPC_CHECK_CHANNEL = "permissions:rpc:check";
35
+
36
+ /** RPC request channel — forward a permission prompt to the parent UI. */
37
+ export const PERMISSIONS_RPC_PROMPT_CHANNEL = "permissions:rpc:prompt";
38
+
39
+ // ── Shared RPC envelope ────────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Standard RPC reply envelope.
43
+ * Success: `{ success: true, protocolVersion, data? }`.
44
+ * Error: `{ success: false, protocolVersion, error }`.
45
+ */
46
+ export type PermissionsRpcReply<T = void> =
47
+ | { success: true; protocolVersion: number; data?: T }
48
+ | { success: false; protocolVersion: number; error: string };
49
+
50
+ // ── permissions:ready ──────────────────────────────────────────────────────
51
+
52
+ /** Payload emitted on `permissions:ready`. */
53
+ export interface PermissionsReadyEvent {
54
+ protocolVersion: number;
55
+ }
56
+
57
+ // ── permissions:decision ───────────────────────────────────────────────────
58
+
59
+ /** How a permission decision was reached. */
60
+ export type PermissionDecisionResolution =
61
+ | "policy_allow"
62
+ | "policy_deny"
63
+ | "session_approved"
64
+ | "infrastructure_auto_allowed"
65
+ | "user_approved"
66
+ | "user_approved_for_session"
67
+ | "user_denied"
68
+ | "auto_approved"
69
+ | "confirmation_unavailable";
70
+
71
+ /** Payload emitted on `permissions:decision`. */
72
+ export interface PermissionDecisionEvent {
73
+ /** Permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
74
+ surface: string;
75
+ /** The value that was evaluated (command, tool name, skill name, path). */
76
+ value: string;
77
+ /** Final decision. */
78
+ result: "allow" | "deny";
79
+ /** How the decision was reached. */
80
+ resolution: PermissionDecisionResolution;
81
+ /** Which config scope contributed the winning rule (when available). */
82
+ origin: string | null;
83
+ /** Agent name (when known). */
84
+ agentName: string | null;
85
+ /** Matched pattern from the winning rule (when available). */
86
+ matchedPattern: string | null;
87
+ }
88
+
89
+ // ── permissions:rpc:check ──────────────────────────────────────────────────
90
+
91
+ /** Request payload for `permissions:rpc:check`. */
92
+ export interface PermissionsCheckRequest {
93
+ requestId: string;
94
+ /** Permission surface to evaluate. */
95
+ surface: string;
96
+ /** The value to evaluate: command string, tool name, skill name, or path. */
97
+ value?: string;
98
+ /** Optional agent name for per-agent policy resolution. */
99
+ agentName?: string;
100
+ }
101
+
102
+ /** Data field in a successful `permissions:rpc:check` reply. */
103
+ export interface PermissionsCheckReplyData {
104
+ result: "allow" | "deny" | "ask";
105
+ matchedPattern: string | null;
106
+ origin: string | null;
107
+ }
108
+
109
+ // ── permissions:rpc:prompt ─────────────────────────────────────────────────
110
+
111
+ /** Request payload for `permissions:rpc:prompt`. */
112
+ export interface PermissionsPromptRequest {
113
+ requestId: string;
114
+ /** Permission surface being evaluated. */
115
+ surface: string;
116
+ /** Value being evaluated (shown in the dialog). */
117
+ value: string;
118
+ /** Optional agent name for display. */
119
+ agentName?: string;
120
+ /** Message to display in the permission dialog. */
121
+ message: string;
122
+ /** Optional label for the "for this session" option. */
123
+ sessionLabel?: string;
124
+ }
125
+
126
+ /** Data field in a successful `permissions:rpc:prompt` reply. */
127
+ export interface PermissionsPromptReplyData {
128
+ approved: boolean;
129
+ /**
130
+ * Detailed state: "approved", "approved_for_session",
131
+ * "denied", or "denied_with_reason".
132
+ */
133
+ state: string;
134
+ denialReason?: string;
135
+ }
136
+
137
+ // ── Emit helpers ───────────────────────────────────────────────────────────
138
+
139
+ /**
140
+ * Emit the `permissions:ready` broadcast.
141
+ * Call once after the extension has finished setup.
142
+ */
143
+ export function emitReadyEvent(events: PermissionEventBus): void {
144
+ const payload: PermissionsReadyEvent = {
145
+ protocolVersion: PERMISSIONS_PROTOCOL_VERSION,
146
+ };
147
+ events.emit(PERMISSIONS_READY_CHANNEL, payload);
148
+ }
149
+
150
+ /**
151
+ * Emit a `permissions:decision` broadcast.
152
+ * Call after every permission gate resolution.
153
+ */
154
+ export function emitDecisionEvent(
155
+ events: PermissionEventBus,
156
+ event: PermissionDecisionEvent,
157
+ ): void {
158
+ events.emit(PERMISSIONS_DECISION_CHANNEL, event);
159
+ }
@@ -66,7 +66,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
66
66
  ): Promise<PermissionPromptDecision> {
67
67
  if (shouldAutoApprovePermissionState("ask", this.deps.getConfig())) {
68
68
  this.writeReviewEntry("permission_request.auto_approved", details);
69
- return { approved: true, state: "approved" };
69
+ return { approved: true, state: "approved", autoApproved: true };
70
70
  }
71
71
 
72
72
  this.writeReviewEntry("permission_request.waiting", details);
@@ -101,8 +101,10 @@ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
101
101
  .fn()
102
102
  .mockResolvedValue({ approved: true, state: "approved" }),
103
103
  createPermissionRequestId: vi.fn().mockReturnValue("test-id"),
104
+ events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
104
105
  startForwardedPermissionPolling: vi.fn(),
105
106
  stopForwardedPermissionPolling: vi.fn(),
107
+ stopPermissionRpcHandlers: vi.fn(),
106
108
  getAllTools: vi.fn().mockReturnValue([]),
107
109
  setActiveTools: vi.fn(),
108
110
  ...overrides,
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Tests that handleInput emits permissions:decision events for skill input gates.
3
+ */
4
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
5
+ import { describe, expect, it, vi } from "vitest";
6
+
7
+ import { handleInput } from "../../src/handlers/input";
8
+ import type { HandlerDeps } from "../../src/handlers/types";
9
+ import type { PermissionDecisionEvent } from "../../src/permission-events";
10
+ import { PERMISSIONS_DECISION_CHANNEL } from "../../src/permission-events";
11
+ import type { ExtensionRuntime } from "../../src/runtime";
12
+
13
+ // ── helpers ────────────────────────────────────────────────────────────────
14
+
15
+ function makeEvents() {
16
+ return {
17
+ emit: vi.fn(),
18
+ on: vi.fn().mockReturnValue(() => undefined),
19
+ };
20
+ }
21
+
22
+ function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
23
+ return {
24
+ cwd: "/test/project",
25
+ hasUI: true,
26
+ ui: {
27
+ setStatus: vi.fn(),
28
+ notify: vi.fn(),
29
+ select: vi.fn(),
30
+ input: vi.fn(),
31
+ },
32
+ sessionManager: {
33
+ getEntries: vi.fn().mockReturnValue([]),
34
+ getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
35
+ addEntry: vi.fn(),
36
+ },
37
+ ...overrides,
38
+ } as unknown as ExtensionContext;
39
+ }
40
+
41
+ function makeRuntime(
42
+ state: "allow" | "deny" | "ask" = "allow",
43
+ ): ExtensionRuntime {
44
+ return {
45
+ agentDir: "/test/agent",
46
+ sessionsDir: "/test/agent/sessions",
47
+ subagentSessionsDir: "/test/agent/subagent-sessions",
48
+ forwardingDir: "/test/agent/sessions/permission-forwarding",
49
+ globalLogsDir: "/test/agent/extensions/pi-permission-system/logs",
50
+ piInfrastructureDirs: ["/test/agent"],
51
+ config: { debugLog: false, permissionReviewLog: true, yoloMode: false },
52
+ runtimeContext: null,
53
+ permissionManager: {
54
+ checkPermission: vi.fn().mockReturnValue({
55
+ state,
56
+ toolName: "skill",
57
+ source: "skill",
58
+ origin: "global",
59
+ matchedPattern: "*",
60
+ }),
61
+ } as unknown as ExtensionRuntime["permissionManager"],
62
+ activeSkillEntries: [],
63
+ lastKnownActiveAgentName: null,
64
+ lastActiveToolsCacheKey: null,
65
+ lastPromptStateCacheKey: null,
66
+ lastConfigWarning: null,
67
+ sessionRules: {
68
+ approve: vi.fn(),
69
+ getRuleset: vi.fn().mockReturnValue([]),
70
+ clear: vi.fn(),
71
+ } as unknown as ExtensionRuntime["sessionRules"],
72
+ permissionForwardingContext: null,
73
+ permissionForwardingTimer: null,
74
+ isProcessingForwardedRequests: false,
75
+ writeDebugLog: vi.fn(),
76
+ writeReviewLog: vi.fn(),
77
+ } as ExtensionRuntime;
78
+ }
79
+
80
+ function makeDeps(
81
+ state: "allow" | "deny" | "ask" = "allow",
82
+ overrides: Partial<HandlerDeps> = {},
83
+ ): HandlerDeps {
84
+ return {
85
+ runtime: makeRuntime(state),
86
+ events: makeEvents(),
87
+ createPermissionManagerForCwd: vi.fn(),
88
+ refreshExtensionConfig: vi.fn(),
89
+ notifyWarning: vi.fn(),
90
+ logResolvedConfigPaths: vi.fn(),
91
+ resolveAgentName: vi.fn().mockReturnValue(null),
92
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
93
+ promptPermission: vi
94
+ .fn()
95
+ .mockResolvedValue({ approved: true, state: "approved" }),
96
+ createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
97
+ startForwardedPermissionPolling: vi.fn(),
98
+ stopForwardedPermissionPolling: vi.fn(),
99
+ stopPermissionRpcHandlers: vi.fn(),
100
+ getAllTools: vi.fn().mockReturnValue([]),
101
+ setActiveTools: vi.fn(),
102
+ ...overrides,
103
+ };
104
+ }
105
+
106
+ function getDecisionEvents(deps: HandlerDeps): PermissionDecisionEvent[] {
107
+ const emitMock = (deps.events as ReturnType<typeof makeEvents>).emit;
108
+ return emitMock.mock.calls
109
+ .filter(([channel]) => channel === PERMISSIONS_DECISION_CHANNEL)
110
+ .map(([, payload]) => payload as PermissionDecisionEvent);
111
+ }
112
+
113
+ // ── tests ──────────────────────────────────────────────────────────────────
114
+
115
+ describe("handleInput decision events — skill gate", () => {
116
+ it("does not emit when input is not a skill invocation", async () => {
117
+ const deps = makeDeps();
118
+ await handleInput(deps, { text: "hello world" }, makeCtx());
119
+ expect(getDecisionEvents(deps)).toHaveLength(0);
120
+ });
121
+
122
+ it("emits allow with policy_allow for an allowed skill", async () => {
123
+ const deps = makeDeps("allow");
124
+ await handleInput(deps, { text: "/skill:librarian" }, makeCtx());
125
+
126
+ const events = getDecisionEvents(deps);
127
+ expect(events).toHaveLength(1);
128
+ expect(events[0]).toMatchObject({
129
+ surface: "skill",
130
+ value: "librarian",
131
+ result: "allow",
132
+ resolution: "policy_allow",
133
+ });
134
+ });
135
+
136
+ it("emits deny with policy_deny for a denied skill", async () => {
137
+ const deps = makeDeps("deny");
138
+ await handleInput(deps, { text: "/skill:restricted" }, makeCtx());
139
+
140
+ const events = getDecisionEvents(deps);
141
+ expect(events).toHaveLength(1);
142
+ expect(events[0]).toMatchObject({
143
+ surface: "skill",
144
+ value: "restricted",
145
+ result: "deny",
146
+ resolution: "policy_deny",
147
+ });
148
+ });
149
+
150
+ it("emits allow with user_approved when state=ask and user approves", async () => {
151
+ const deps = makeDeps("ask", {
152
+ promptPermission: vi
153
+ .fn()
154
+ .mockResolvedValue({ approved: true, state: "approved" }),
155
+ });
156
+ await handleInput(deps, { text: "/skill:explorer" }, makeCtx());
157
+
158
+ const events = getDecisionEvents(deps);
159
+ expect(events).toHaveLength(1);
160
+ expect(events[0]).toMatchObject({
161
+ surface: "skill",
162
+ value: "explorer",
163
+ result: "allow",
164
+ resolution: "user_approved",
165
+ });
166
+ });
167
+
168
+ it("emits deny with user_denied when state=ask and user denies", async () => {
169
+ const deps = makeDeps("ask", {
170
+ promptPermission: vi
171
+ .fn()
172
+ .mockResolvedValue({ approved: false, state: "denied" }),
173
+ });
174
+ await handleInput(deps, { text: "/skill:explorer" }, makeCtx());
175
+
176
+ const events = getDecisionEvents(deps);
177
+ expect(events).toHaveLength(1);
178
+ expect(events[0]).toMatchObject({
179
+ surface: "skill",
180
+ value: "explorer",
181
+ result: "deny",
182
+ resolution: "user_denied",
183
+ });
184
+ });
185
+
186
+ it("emits deny with confirmation_unavailable when state=ask but no UI", async () => {
187
+ const deps = makeDeps("ask", {
188
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
189
+ });
190
+ await handleInput(
191
+ deps,
192
+ { text: "/skill:explorer" },
193
+ makeCtx({ hasUI: false }),
194
+ );
195
+
196
+ const events = getDecisionEvents(deps);
197
+ expect(events).toHaveLength(1);
198
+ expect(events[0]).toMatchObject({
199
+ surface: "skill",
200
+ value: "explorer",
201
+ result: "deny",
202
+ resolution: "confirmation_unavailable",
203
+ });
204
+ });
205
+
206
+ it("emits allow with auto_approved when promptPermission returns autoApproved:true", async () => {
207
+ const deps = makeDeps("ask", {
208
+ // Simulate what PermissionPrompter returns in yolo mode
209
+ promptPermission: vi.fn().mockResolvedValue({
210
+ approved: true,
211
+ state: "approved",
212
+ autoApproved: true,
213
+ }),
214
+ });
215
+ await handleInput(deps, { text: "/skill:explorer" }, makeCtx());
216
+
217
+ const events = getDecisionEvents(deps);
218
+ expect(events).toHaveLength(1);
219
+ expect(events[0]).toMatchObject({
220
+ surface: "skill",
221
+ value: "explorer",
222
+ result: "allow",
223
+ resolution: "auto_approved",
224
+ });
225
+ });
226
+ });
@@ -80,8 +80,10 @@ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
80
80
  .fn()
81
81
  .mockResolvedValue({ approved: true, state: "approved" }),
82
82
  createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
83
+ events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
83
84
  startForwardedPermissionPolling: vi.fn(),
84
85
  stopForwardedPermissionPolling: vi.fn(),
86
+ stopPermissionRpcHandlers: vi.fn(),
85
87
  getAllTools: vi.fn().mockReturnValue([]),
86
88
  setActiveTools: vi.fn(),
87
89
  ...overrides,
@@ -109,8 +109,10 @@ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
109
109
  .fn()
110
110
  .mockResolvedValue({ approved: true, state: "approved" }),
111
111
  createPermissionRequestId: vi.fn().mockReturnValue("test-id"),
112
+ events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
112
113
  startForwardedPermissionPolling: vi.fn(),
113
114
  stopForwardedPermissionPolling: vi.fn(),
115
+ stopPermissionRpcHandlers: vi.fn(),
114
116
  getAllTools: vi.fn().mockReturnValue([]),
115
117
  setActiveTools: vi.fn(),
116
118
  ...overrides,
@@ -341,6 +343,12 @@ describe("handleSessionShutdown", () => {
341
343
  expect(deps.stopForwardedPermissionPolling).toHaveBeenCalledOnce();
342
344
  });
343
345
 
346
+ it("calls stopPermissionRpcHandlers on shutdown", async () => {
347
+ const deps = makeDeps();
348
+ await handleSessionShutdown(deps);
349
+ expect(deps.stopPermissionRpcHandlers).toHaveBeenCalledOnce();
350
+ });
351
+
344
352
  it("does not reset lastKnownActiveAgentName", async () => {
345
353
  const deps = makeDeps({
346
354
  runtime: makeRuntime({ lastKnownActiveAgentName: "remembered" }),