@gotgenes/pi-permission-system 5.9.0 → 5.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/package.json +1 -1
- package/src/forwarding-manager.ts +1 -1
- package/src/handlers/before-agent-start.ts +76 -76
- package/src/handlers/gates/descriptor.ts +1 -1
- package/src/handlers/index.ts +6 -15
- package/src/handlers/lifecycle.ts +55 -59
- package/src/handlers/permission-gate-handler.ts +346 -0
- package/src/index.ts +46 -54
- package/src/permission-prompter.ts +23 -6
- package/src/permission-session.ts +281 -0
- package/src/runtime.ts +5 -30
- package/src/session-logger.ts +1 -1
- package/src/skill-prompt-sanitizer.ts +15 -4
- package/src/tool-registry.ts +6 -0
- package/tests/handlers/before-agent-start.test.ts +116 -167
- package/tests/handlers/input-events.test.ts +87 -92
- package/tests/handlers/input.test.ts +98 -128
- package/tests/handlers/lifecycle.test.ts +97 -227
- package/tests/handlers/tool-call-events.test.ts +146 -166
- package/tests/handlers/tool-call.test.ts +102 -97
- package/tests/permission-prompter.test.ts +1 -1
- package/tests/permission-session.test.ts +607 -0
- package/tests/runtime.test.ts +2 -77
- package/src/handlers/input.ts +0 -126
- package/src/handlers/tool-call.ts +0 -210
- package/src/handlers/types.ts +0 -90
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
6
6
|
import { describe, expect, it, vi } from "vitest";
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
import type { HandlerDeps } from "../../src/handlers/types";
|
|
8
|
+
import { PermissionGateHandler } from "../../src/handlers/permission-gate-handler";
|
|
10
9
|
import type { PermissionDecisionEvent } from "../../src/permission-events";
|
|
11
10
|
import { PERMISSIONS_DECISION_CHANNEL } from "../../src/permission-events";
|
|
12
|
-
import type {
|
|
13
|
-
import type {
|
|
11
|
+
import type { PermissionSession } from "../../src/permission-session";
|
|
12
|
+
import type { ToolRegistry } from "../../src/tool-registry";
|
|
13
|
+
import type { PermissionCheckResult, PermissionState } from "../../src/types";
|
|
14
14
|
|
|
15
15
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
16
16
|
|
|
@@ -67,53 +67,56 @@ function makeCheckResult(
|
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
function makeSession(
|
|
70
|
+
function makeSession(
|
|
71
|
+
overrides: Partial<Record<keyof PermissionSession, unknown>> = {},
|
|
72
|
+
): PermissionSession {
|
|
71
73
|
return {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
74
|
+
logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
|
|
75
|
+
activate: vi.fn(),
|
|
76
|
+
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
77
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
|
|
78
|
+
getToolPermission: vi.fn().mockReturnValue("allow" as PermissionState),
|
|
79
|
+
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
80
|
+
approveSessionRule: vi.fn(),
|
|
81
|
+
getActiveSkillEntries: vi.fn().mockReturnValue([]),
|
|
82
|
+
getInfrastructureDirs: vi
|
|
83
|
+
.fn()
|
|
84
|
+
.mockReturnValue(["/test/agent", "/test/agent/git"]),
|
|
85
|
+
getInfrastructureReadPaths: vi.fn().mockReturnValue([]),
|
|
86
|
+
canPrompt: vi.fn().mockReturnValue(true),
|
|
87
|
+
prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
|
|
85
88
|
...overrides,
|
|
86
|
-
};
|
|
89
|
+
} as unknown as PermissionSession;
|
|
87
90
|
}
|
|
88
91
|
|
|
89
|
-
function
|
|
92
|
+
function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
|
|
90
93
|
return {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
|
|
94
|
-
getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
|
|
95
|
-
events: makeEvents(),
|
|
96
|
-
createPermissionManagerForCwd: vi.fn(),
|
|
97
|
-
refreshExtensionConfig: vi.fn(),
|
|
98
|
-
logResolvedConfigPaths: vi.fn(),
|
|
99
|
-
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
100
|
-
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
101
|
-
promptPermission: vi
|
|
102
|
-
.fn()
|
|
103
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
104
|
-
createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
|
|
105
|
-
forwarding: { start: vi.fn(), stop: vi.fn() },
|
|
106
|
-
stopPermissionRpcHandlers: vi.fn(),
|
|
107
|
-
getAllTools: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]),
|
|
108
|
-
setActiveTools: vi.fn(),
|
|
94
|
+
getAll: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]),
|
|
95
|
+
setActive: vi.fn(),
|
|
109
96
|
...overrides,
|
|
110
97
|
};
|
|
111
98
|
}
|
|
112
99
|
|
|
100
|
+
function makeHandler(overrides?: {
|
|
101
|
+
session?: Partial<Record<keyof PermissionSession, unknown>>;
|
|
102
|
+
toolRegistry?: Partial<ToolRegistry>;
|
|
103
|
+
}): {
|
|
104
|
+
handler: PermissionGateHandler;
|
|
105
|
+
events: ReturnType<typeof makeEvents>;
|
|
106
|
+
session: PermissionSession;
|
|
107
|
+
} {
|
|
108
|
+
const session = makeSession(overrides?.session);
|
|
109
|
+
const events = makeEvents();
|
|
110
|
+
const toolRegistry = makeToolRegistry(overrides?.toolRegistry);
|
|
111
|
+
const handler = new PermissionGateHandler(session, events, toolRegistry);
|
|
112
|
+
return { handler, events, session };
|
|
113
|
+
}
|
|
114
|
+
|
|
113
115
|
/** Extract all permissions:decision payloads from the events.emit mock. */
|
|
114
|
-
function getDecisionEvents(
|
|
115
|
-
|
|
116
|
-
|
|
116
|
+
function getDecisionEvents(
|
|
117
|
+
events: ReturnType<typeof makeEvents>,
|
|
118
|
+
): PermissionDecisionEvent[] {
|
|
119
|
+
return events.emit.mock.calls
|
|
117
120
|
.filter(([channel]) => channel === PERMISSIONS_DECISION_CHANNEL)
|
|
118
121
|
.map(([, payload]) => payload as PermissionDecisionEvent);
|
|
119
122
|
}
|
|
@@ -122,24 +125,22 @@ function getDecisionEvents(deps: HandlerDeps): PermissionDecisionEvent[] {
|
|
|
122
125
|
|
|
123
126
|
describe("handleToolCall decision events — policy_allow", () => {
|
|
124
127
|
it("emits allow with policy_allow when checkPermission returns allow", async () => {
|
|
125
|
-
const
|
|
126
|
-
session:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
} as unknown as SessionState["permissionManager"],
|
|
135
|
-
}),
|
|
128
|
+
const { handler, events } = makeHandler({
|
|
129
|
+
session: {
|
|
130
|
+
checkPermission: vi.fn().mockReturnValue(
|
|
131
|
+
makeCheckResult("allow", {
|
|
132
|
+
origin: "global",
|
|
133
|
+
matchedPattern: "*",
|
|
134
|
+
}),
|
|
135
|
+
),
|
|
136
|
+
},
|
|
136
137
|
});
|
|
137
138
|
|
|
138
|
-
await handleToolCall(
|
|
139
|
+
await handler.handleToolCall(makeToolCallEvent("read"), makeCtx());
|
|
139
140
|
|
|
140
|
-
const
|
|
141
|
-
expect(
|
|
142
|
-
expect(
|
|
141
|
+
const decisions = getDecisionEvents(events);
|
|
142
|
+
expect(decisions).toHaveLength(1);
|
|
143
|
+
expect(decisions[0]).toMatchObject({
|
|
143
144
|
surface: "read",
|
|
144
145
|
result: "allow",
|
|
145
146
|
resolution: "policy_allow",
|
|
@@ -153,24 +154,22 @@ describe("handleToolCall decision events — policy_allow", () => {
|
|
|
153
154
|
|
|
154
155
|
describe("handleToolCall decision events — policy_deny", () => {
|
|
155
156
|
it("emits deny with policy_deny when checkPermission returns deny", async () => {
|
|
156
|
-
const
|
|
157
|
-
session:
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
} as unknown as SessionState["permissionManager"],
|
|
166
|
-
}),
|
|
157
|
+
const { handler, events } = makeHandler({
|
|
158
|
+
session: {
|
|
159
|
+
checkPermission: vi.fn().mockReturnValue(
|
|
160
|
+
makeCheckResult("deny", {
|
|
161
|
+
origin: "project",
|
|
162
|
+
matchedPattern: "read",
|
|
163
|
+
}),
|
|
164
|
+
),
|
|
165
|
+
},
|
|
167
166
|
});
|
|
168
167
|
|
|
169
|
-
await handleToolCall(
|
|
168
|
+
await handler.handleToolCall(makeToolCallEvent("read"), makeCtx());
|
|
170
169
|
|
|
171
|
-
const
|
|
172
|
-
expect(
|
|
173
|
-
expect(
|
|
170
|
+
const decisions = getDecisionEvents(events);
|
|
171
|
+
expect(decisions).toHaveLength(1);
|
|
172
|
+
expect(decisions[0]).toMatchObject({
|
|
174
173
|
surface: "read",
|
|
175
174
|
result: "deny",
|
|
176
175
|
resolution: "policy_deny",
|
|
@@ -182,28 +181,25 @@ describe("handleToolCall decision events — policy_deny", () => {
|
|
|
182
181
|
|
|
183
182
|
describe("handleToolCall decision events — session_approved", () => {
|
|
184
183
|
it("emits allow with session_approved when checkPermission returns source:session", async () => {
|
|
185
|
-
const
|
|
186
|
-
session:
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
} as unknown as SessionState["permissionManager"],
|
|
195
|
-
}),
|
|
184
|
+
const { handler, events } = makeHandler({
|
|
185
|
+
session: {
|
|
186
|
+
checkPermission: vi.fn().mockReturnValue(
|
|
187
|
+
makeCheckResult("allow", {
|
|
188
|
+
source: "session",
|
|
189
|
+
matchedPattern: "git *",
|
|
190
|
+
}),
|
|
191
|
+
),
|
|
192
|
+
},
|
|
196
193
|
});
|
|
197
194
|
|
|
198
|
-
await handleToolCall(
|
|
199
|
-
deps,
|
|
195
|
+
await handler.handleToolCall(
|
|
200
196
|
makeToolCallEvent("bash", { input: { command: "git status" } }),
|
|
201
197
|
makeCtx(),
|
|
202
198
|
);
|
|
203
199
|
|
|
204
|
-
const
|
|
205
|
-
expect(
|
|
206
|
-
expect(
|
|
200
|
+
const decisions = getDecisionEvents(events);
|
|
201
|
+
expect(decisions).toHaveLength(1);
|
|
202
|
+
expect(decisions[0]).toMatchObject({
|
|
207
203
|
surface: "bash",
|
|
208
204
|
result: "allow",
|
|
209
205
|
resolution: "session_approved",
|
|
@@ -215,44 +211,41 @@ describe("handleToolCall decision events — session_approved", () => {
|
|
|
215
211
|
|
|
216
212
|
describe("handleToolCall decision events — user_approved", () => {
|
|
217
213
|
it("emits allow with user_approved when state=ask and user approves once", async () => {
|
|
218
|
-
const
|
|
219
|
-
session:
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
.fn()
|
|
226
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
214
|
+
const { handler, events } = makeHandler({
|
|
215
|
+
session: {
|
|
216
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
217
|
+
prompt: vi
|
|
218
|
+
.fn()
|
|
219
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
220
|
+
},
|
|
227
221
|
});
|
|
228
222
|
|
|
229
|
-
await handleToolCall(
|
|
223
|
+
await handler.handleToolCall(makeToolCallEvent("read"), makeCtx());
|
|
230
224
|
|
|
231
|
-
const
|
|
232
|
-
expect(
|
|
233
|
-
expect(
|
|
225
|
+
const decisions = getDecisionEvents(events);
|
|
226
|
+
expect(decisions).toHaveLength(1);
|
|
227
|
+
expect(decisions[0]).toMatchObject({
|
|
234
228
|
result: "allow",
|
|
235
229
|
resolution: "user_approved",
|
|
236
230
|
});
|
|
237
231
|
});
|
|
238
232
|
|
|
239
233
|
it("emits allow with user_approved_for_session when user approves for session", async () => {
|
|
240
|
-
const
|
|
241
|
-
session:
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
234
|
+
const { handler, events } = makeHandler({
|
|
235
|
+
session: {
|
|
236
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
237
|
+
prompt: vi.fn().mockResolvedValue({
|
|
238
|
+
approved: true,
|
|
239
|
+
state: "approved_for_session",
|
|
240
|
+
}),
|
|
241
|
+
},
|
|
249
242
|
});
|
|
250
243
|
|
|
251
|
-
await handleToolCall(
|
|
244
|
+
await handler.handleToolCall(makeToolCallEvent("read"), makeCtx());
|
|
252
245
|
|
|
253
|
-
const
|
|
254
|
-
expect(
|
|
255
|
-
expect(
|
|
246
|
+
const decisions = getDecisionEvents(events);
|
|
247
|
+
expect(decisions).toHaveLength(1);
|
|
248
|
+
expect(decisions[0]).toMatchObject({
|
|
256
249
|
result: "allow",
|
|
257
250
|
resolution: "user_approved_for_session",
|
|
258
251
|
});
|
|
@@ -263,22 +256,18 @@ describe("handleToolCall decision events — user_approved", () => {
|
|
|
263
256
|
|
|
264
257
|
describe("handleToolCall decision events — user_denied", () => {
|
|
265
258
|
it("emits deny with user_denied when state=ask and user denies", async () => {
|
|
266
|
-
const
|
|
267
|
-
session:
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}),
|
|
272
|
-
promptPermission: vi
|
|
273
|
-
.fn()
|
|
274
|
-
.mockResolvedValue({ approved: false, state: "denied" }),
|
|
259
|
+
const { handler, events } = makeHandler({
|
|
260
|
+
session: {
|
|
261
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
262
|
+
prompt: vi.fn().mockResolvedValue({ approved: false, state: "denied" }),
|
|
263
|
+
},
|
|
275
264
|
});
|
|
276
265
|
|
|
277
|
-
await handleToolCall(
|
|
266
|
+
await handler.handleToolCall(makeToolCallEvent("read"), makeCtx());
|
|
278
267
|
|
|
279
|
-
const
|
|
280
|
-
expect(
|
|
281
|
-
expect(
|
|
268
|
+
const decisions = getDecisionEvents(events);
|
|
269
|
+
expect(decisions).toHaveLength(1);
|
|
270
|
+
expect(decisions[0]).toMatchObject({
|
|
282
271
|
result: "deny",
|
|
283
272
|
resolution: "user_denied",
|
|
284
273
|
});
|
|
@@ -289,24 +278,21 @@ describe("handleToolCall decision events — user_denied", () => {
|
|
|
289
278
|
|
|
290
279
|
describe("handleToolCall decision events — confirmation_unavailable", () => {
|
|
291
280
|
it("emits deny with confirmation_unavailable when state=ask but no UI", async () => {
|
|
292
|
-
const
|
|
293
|
-
session:
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}),
|
|
298
|
-
canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
|
|
281
|
+
const { handler, events } = makeHandler({
|
|
282
|
+
session: {
|
|
283
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
284
|
+
canPrompt: vi.fn().mockReturnValue(false),
|
|
285
|
+
},
|
|
299
286
|
});
|
|
300
287
|
|
|
301
|
-
await handleToolCall(
|
|
302
|
-
deps,
|
|
288
|
+
await handler.handleToolCall(
|
|
303
289
|
makeToolCallEvent("read"),
|
|
304
290
|
makeCtx({ hasUI: false }),
|
|
305
291
|
);
|
|
306
292
|
|
|
307
|
-
const
|
|
308
|
-
expect(
|
|
309
|
-
expect(
|
|
293
|
+
const decisions = getDecisionEvents(events);
|
|
294
|
+
expect(decisions).toHaveLength(1);
|
|
295
|
+
expect(decisions[0]).toMatchObject({
|
|
310
296
|
result: "deny",
|
|
311
297
|
resolution: "confirmation_unavailable",
|
|
312
298
|
});
|
|
@@ -318,23 +304,20 @@ describe("handleToolCall decision events — confirmation_unavailable", () => {
|
|
|
318
304
|
describe("handleToolCall decision events — infrastructure_auto_allowed", () => {
|
|
319
305
|
it("emits allow with infrastructure_auto_allowed for Pi infra reads", async () => {
|
|
320
306
|
const infraDir = "/test/agent";
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
} as unknown as SessionState["permissionManager"],
|
|
327
|
-
}),
|
|
307
|
+
const { handler, events } = makeHandler({
|
|
308
|
+
session: {
|
|
309
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
|
|
310
|
+
getInfrastructureDirs: vi.fn().mockReturnValue([infraDir]),
|
|
311
|
+
},
|
|
328
312
|
});
|
|
329
313
|
|
|
330
314
|
const event = makeToolCallEvent("read", {
|
|
331
315
|
input: { path: `${infraDir}/some-file.json` },
|
|
332
316
|
});
|
|
333
|
-
await handleToolCall(
|
|
317
|
+
await handler.handleToolCall(event, makeCtx());
|
|
334
318
|
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
const infraEvents = events.filter(
|
|
319
|
+
const decisions = getDecisionEvents(events);
|
|
320
|
+
const infraEvents = decisions.filter(
|
|
338
321
|
(e) => e.resolution === "infrastructure_auto_allowed",
|
|
339
322
|
);
|
|
340
323
|
expect(infraEvents).toHaveLength(1);
|
|
@@ -348,26 +331,23 @@ describe("handleToolCall decision events — infrastructure_auto_allowed", () =>
|
|
|
348
331
|
// ── auto_approved path (yolo mode) ───────────────────────────────────
|
|
349
332
|
|
|
350
333
|
describe("handleToolCall decision events — auto_approved", () => {
|
|
351
|
-
it("emits allow with auto_approved when
|
|
352
|
-
const
|
|
353
|
-
session:
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
state: "approved",
|
|
362
|
-
autoApproved: true,
|
|
363
|
-
}),
|
|
334
|
+
it("emits allow with auto_approved when prompt returns autoApproved:true", async () => {
|
|
335
|
+
const { handler, events } = makeHandler({
|
|
336
|
+
session: {
|
|
337
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
338
|
+
prompt: vi.fn().mockResolvedValue({
|
|
339
|
+
approved: true,
|
|
340
|
+
state: "approved",
|
|
341
|
+
autoApproved: true,
|
|
342
|
+
}),
|
|
343
|
+
},
|
|
364
344
|
});
|
|
365
345
|
|
|
366
|
-
await handleToolCall(
|
|
346
|
+
await handler.handleToolCall(makeToolCallEvent("read"), makeCtx());
|
|
367
347
|
|
|
368
|
-
const
|
|
369
|
-
expect(
|
|
370
|
-
expect(
|
|
348
|
+
const decisions = getDecisionEvents(events);
|
|
349
|
+
expect(decisions).toHaveLength(1);
|
|
350
|
+
expect(decisions[0]).toMatchObject({
|
|
371
351
|
result: "allow",
|
|
372
352
|
resolution: "auto_approved",
|
|
373
353
|
});
|