@gotgenes/pi-permission-system 5.2.1 → 5.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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" }),