@gotgenes/pi-permission-system 10.3.1 → 10.4.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 +12 -0
- package/package.json +1 -1
- package/src/config-modal.ts +10 -8
- package/src/config-store.ts +6 -11
- package/src/forwarded-permissions/io.ts +16 -22
- package/src/forwarded-permissions/permission-forwarder.ts +16 -19
- package/src/gate-prompter.ts +1 -3
- package/src/handlers/gates/runner.ts +1 -1
- package/src/index.ts +22 -40
- package/src/permission-event-rpc.ts +19 -15
- package/src/permission-prompter.ts +4 -3
- package/src/permission-session.ts +7 -63
- package/src/prompting-gateway.ts +104 -0
- package/src/session-logger.ts +17 -3
- package/test/config-modal.test.ts +13 -7
- package/test/config-store.test.ts +7 -9
- package/test/forwarded-permissions/io.test.ts +23 -26
- package/test/handlers/external-directory-integration.test.ts +45 -32
- package/test/handlers/external-directory-session-dedup.test.ts +36 -46
- package/test/handlers/gates/runner.test.ts +10 -16
- package/test/handlers/input-events.test.ts +19 -4
- package/test/handlers/input.test.ts +29 -13
- package/test/handlers/tool-call-events.test.ts +23 -5
- package/test/helpers/gate-fixtures.ts +6 -6
- package/test/helpers/handler-fixtures.ts +24 -39
- package/test/permission-event-rpc.test.ts +30 -28
- package/test/permission-forwarder.test.ts +6 -5
- package/test/permission-prompter.test.ts +28 -28
- package/test/permission-session.test.ts +27 -112
- package/test/prompting-gateway.test.ts +230 -0
|
@@ -9,16 +9,15 @@
|
|
|
9
9
|
* PermissionManager.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
12
|
import { describe, expect, it, vi } from "vitest";
|
|
14
13
|
|
|
15
14
|
import { GateDecisionReporter } from "#src/decision-reporter";
|
|
16
15
|
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
16
|
+
import type { GatePrompter } from "#src/gate-prompter";
|
|
17
17
|
import { GateRunner } from "#src/handlers/gates/runner";
|
|
18
18
|
import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
|
|
19
19
|
import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
|
|
20
20
|
import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
|
|
21
|
-
import type { PromptPermissionDetails } from "#src/permission-prompter";
|
|
22
21
|
import type { Rule } from "#src/rule";
|
|
23
22
|
import type { SessionApproval } from "#src/session-approval";
|
|
24
23
|
import { resolveToolPreviewLimits } from "#src/tool-preview-formatter";
|
|
@@ -154,15 +153,7 @@ function makeStatefulSession(
|
|
|
154
153
|
vi
|
|
155
154
|
.fn<MockGateHandlerSession["getToolPreviewLimits"]>()
|
|
156
155
|
.mockReturnValue(resolveToolPreviewLimits(DEFAULT_EXTENSION_CONFIG)),
|
|
157
|
-
|
|
158
|
-
overrides.canPrompt ??
|
|
159
|
-
vi.fn<MockGateHandlerSession["canPrompt"]>().mockReturnValue(true),
|
|
160
|
-
prompt:
|
|
161
|
-
overrides.prompt ??
|
|
162
|
-
vi
|
|
163
|
-
.fn<MockGateHandlerSession["prompt"]>()
|
|
164
|
-
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
165
|
-
// Delegations — closures read `session` at call time so overrides win.
|
|
156
|
+
// Resolve delegation — closure reads `session` at call time so overrides win.
|
|
166
157
|
resolve:
|
|
167
158
|
overrides.resolve ??
|
|
168
159
|
vi.fn<MockGateHandlerSession["resolve"]>((surface, input, agentName) =>
|
|
@@ -173,34 +164,31 @@ function makeStatefulSession(
|
|
|
173
164
|
session.getSessionRuleset(),
|
|
174
165
|
),
|
|
175
166
|
),
|
|
176
|
-
canConfirm:
|
|
177
|
-
overrides.canConfirm ??
|
|
178
|
-
vi.fn<MockGateHandlerSession["canConfirm"]>(() =>
|
|
179
|
-
session.canPrompt(undefined as unknown as ExtensionContext),
|
|
180
|
-
),
|
|
181
|
-
promptPermission:
|
|
182
|
-
overrides.promptPermission ??
|
|
183
|
-
vi.fn<MockGateHandlerSession["promptPermission"]>(
|
|
184
|
-
(details: PromptPermissionDetails) =>
|
|
185
|
-
session.prompt(undefined as unknown as ExtensionContext, details),
|
|
186
|
-
),
|
|
187
167
|
};
|
|
188
168
|
return session;
|
|
189
169
|
}
|
|
190
170
|
|
|
191
171
|
function makeHandlerForSession(
|
|
192
172
|
session: MockGateHandlerSession,
|
|
193
|
-
|
|
173
|
+
prompter?: GatePrompter,
|
|
174
|
+
): { handler: PermissionGateHandler; prompter: GatePrompter } {
|
|
194
175
|
const events = makeEvents();
|
|
195
176
|
const reporter = new GateDecisionReporter(session.logger, events);
|
|
196
|
-
const
|
|
197
|
-
|
|
177
|
+
const resolvedPrompter: GatePrompter = prompter ?? {
|
|
178
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
179
|
+
prompt: vi
|
|
180
|
+
.fn<GatePrompter["prompt"]>()
|
|
181
|
+
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
182
|
+
};
|
|
183
|
+
const runner = new GateRunner(session, session, resolvedPrompter, reporter);
|
|
184
|
+
const handler = new PermissionGateHandler(
|
|
198
185
|
session,
|
|
199
186
|
makeToolRegistry(),
|
|
200
187
|
new ToolCallGatePipeline(session),
|
|
201
188
|
new SkillInputGatePipeline(session),
|
|
202
189
|
runner,
|
|
203
190
|
);
|
|
191
|
+
return { handler, prompter: resolvedPrompter };
|
|
204
192
|
}
|
|
205
193
|
|
|
206
194
|
function makeToolRegistry(): ToolRegistry {
|
|
@@ -223,7 +211,7 @@ describe("external-directory session dedup", () => {
|
|
|
223
211
|
describe("path-bearing tools (read, write, edit)", () => {
|
|
224
212
|
it("does not re-prompt for the same external path after session approval", async () => {
|
|
225
213
|
const session = makeStatefulSession();
|
|
226
|
-
const handler = makeHandlerForSession(session);
|
|
214
|
+
const { handler, prompter } = makeHandlerForSession(session);
|
|
227
215
|
const ctx = makeCtx();
|
|
228
216
|
const externalPath = "/outside/project/data.txt";
|
|
229
217
|
|
|
@@ -236,7 +224,7 @@ describe("external-directory session dedup", () => {
|
|
|
236
224
|
};
|
|
237
225
|
const result1 = await handler.handleToolCall(event1, ctx);
|
|
238
226
|
expect(result1).toEqual({});
|
|
239
|
-
expect(
|
|
227
|
+
expect(prompter.prompt).toHaveBeenCalledTimes(1);
|
|
240
228
|
|
|
241
229
|
// Second call — same path, should hit session rule, no prompt
|
|
242
230
|
const event2 = {
|
|
@@ -247,12 +235,12 @@ describe("external-directory session dedup", () => {
|
|
|
247
235
|
};
|
|
248
236
|
const result2 = await handler.handleToolCall(event2, ctx);
|
|
249
237
|
expect(result2).toEqual({});
|
|
250
|
-
expect(
|
|
238
|
+
expect(prompter.prompt).toHaveBeenCalledTimes(1);
|
|
251
239
|
});
|
|
252
240
|
|
|
253
241
|
it("does not re-prompt for a different file in the same external directory", async () => {
|
|
254
242
|
const session = makeStatefulSession();
|
|
255
|
-
const handler = makeHandlerForSession(session);
|
|
243
|
+
const { handler, prompter } = makeHandlerForSession(session);
|
|
256
244
|
const ctx = makeCtx();
|
|
257
245
|
|
|
258
246
|
// First call — prompt for /outside/project/a.txt
|
|
@@ -263,7 +251,7 @@ describe("external-directory session dedup", () => {
|
|
|
263
251
|
input: { path: "/outside/project/a.txt" },
|
|
264
252
|
};
|
|
265
253
|
await handler.handleToolCall(event1, ctx);
|
|
266
|
-
expect(
|
|
254
|
+
expect(prompter.prompt).toHaveBeenCalledTimes(1);
|
|
267
255
|
|
|
268
256
|
// Second call — /outside/project/b.txt is in the same directory
|
|
269
257
|
const event2 = {
|
|
@@ -273,12 +261,12 @@ describe("external-directory session dedup", () => {
|
|
|
273
261
|
input: { path: "/outside/project/b.txt" },
|
|
274
262
|
};
|
|
275
263
|
await handler.handleToolCall(event2, ctx);
|
|
276
|
-
expect(
|
|
264
|
+
expect(prompter.prompt).toHaveBeenCalledTimes(1);
|
|
277
265
|
});
|
|
278
266
|
|
|
279
267
|
it("does prompt for a file in a different external directory", async () => {
|
|
280
268
|
const session = makeStatefulSession();
|
|
281
|
-
const handler = makeHandlerForSession(session);
|
|
269
|
+
const { handler, prompter } = makeHandlerForSession(session);
|
|
282
270
|
const ctx = makeCtx();
|
|
283
271
|
|
|
284
272
|
// First call — /outside/alpha/file.txt
|
|
@@ -289,7 +277,7 @@ describe("external-directory session dedup", () => {
|
|
|
289
277
|
input: { path: "/outside/alpha/file.txt" },
|
|
290
278
|
};
|
|
291
279
|
await handler.handleToolCall(event1, ctx);
|
|
292
|
-
expect(
|
|
280
|
+
expect(prompter.prompt).toHaveBeenCalledTimes(1);
|
|
293
281
|
|
|
294
282
|
// Second call — /outside/beta/file.txt is a different directory
|
|
295
283
|
const event2 = {
|
|
@@ -299,16 +287,18 @@ describe("external-directory session dedup", () => {
|
|
|
299
287
|
input: { path: "/outside/beta/file.txt" },
|
|
300
288
|
};
|
|
301
289
|
await handler.handleToolCall(event2, ctx);
|
|
302
|
-
expect(
|
|
290
|
+
expect(prompter.prompt).toHaveBeenCalledTimes(2);
|
|
303
291
|
});
|
|
304
292
|
|
|
305
293
|
it("re-prompts when user approved once (not for session)", async () => {
|
|
306
|
-
const session = makeStatefulSession(
|
|
294
|
+
const session = makeStatefulSession();
|
|
295
|
+
const approveOnce: GatePrompter = {
|
|
296
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
307
297
|
prompt: vi
|
|
308
|
-
.fn()
|
|
298
|
+
.fn<GatePrompter["prompt"]>()
|
|
309
299
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
310
|
-
}
|
|
311
|
-
const handler = makeHandlerForSession(session);
|
|
300
|
+
};
|
|
301
|
+
const { handler, prompter } = makeHandlerForSession(session, approveOnce);
|
|
312
302
|
const ctx = makeCtx();
|
|
313
303
|
const externalPath = "/outside/project/data.txt";
|
|
314
304
|
|
|
@@ -320,7 +310,7 @@ describe("external-directory session dedup", () => {
|
|
|
320
310
|
input: { path: externalPath },
|
|
321
311
|
};
|
|
322
312
|
await handler.handleToolCall(event1, ctx);
|
|
323
|
-
expect(
|
|
313
|
+
expect(prompter.prompt).toHaveBeenCalledTimes(1);
|
|
324
314
|
|
|
325
315
|
// Second call — no session rule recorded, should prompt again
|
|
326
316
|
const event2 = {
|
|
@@ -330,14 +320,14 @@ describe("external-directory session dedup", () => {
|
|
|
330
320
|
input: { path: externalPath },
|
|
331
321
|
};
|
|
332
322
|
await handler.handleToolCall(event2, ctx);
|
|
333
|
-
expect(
|
|
323
|
+
expect(prompter.prompt).toHaveBeenCalledTimes(2);
|
|
334
324
|
});
|
|
335
325
|
});
|
|
336
326
|
|
|
337
327
|
describe("bash commands with external paths", () => {
|
|
338
328
|
it("does not re-prompt for a bash command referencing the same external path after session approval", async () => {
|
|
339
329
|
const session = makeStatefulSession();
|
|
340
|
-
const handler = makeHandlerForSession(session);
|
|
330
|
+
const { handler, prompter } = makeHandlerForSession(session);
|
|
341
331
|
const ctx = makeCtx();
|
|
342
332
|
|
|
343
333
|
// First call — bash referencing /tmp/out.txt
|
|
@@ -349,7 +339,7 @@ describe("external-directory session dedup", () => {
|
|
|
349
339
|
};
|
|
350
340
|
const result1 = await handler.handleToolCall(event1, ctx);
|
|
351
341
|
expect(result1).toEqual({});
|
|
352
|
-
expect(
|
|
342
|
+
expect(prompter.prompt).toHaveBeenCalledTimes(1);
|
|
353
343
|
|
|
354
344
|
// Second call — different bash command, same external path
|
|
355
345
|
const event2 = {
|
|
@@ -360,12 +350,12 @@ describe("external-directory session dedup", () => {
|
|
|
360
350
|
};
|
|
361
351
|
const result2 = await handler.handleToolCall(event2, ctx);
|
|
362
352
|
expect(result2).toEqual({});
|
|
363
|
-
expect(
|
|
353
|
+
expect(prompter.prompt).toHaveBeenCalledTimes(1);
|
|
364
354
|
});
|
|
365
355
|
|
|
366
356
|
it("does not re-prompt for read after bash already approved the same directory", async () => {
|
|
367
357
|
const session = makeStatefulSession();
|
|
368
|
-
const handler = makeHandlerForSession(session);
|
|
358
|
+
const { handler, prompter } = makeHandlerForSession(session);
|
|
369
359
|
const ctx = makeCtx();
|
|
370
360
|
|
|
371
361
|
// First call — bash writes to /tmp/out.txt
|
|
@@ -376,7 +366,7 @@ describe("external-directory session dedup", () => {
|
|
|
376
366
|
input: { command: "echo hello > /tmp/out.txt" },
|
|
377
367
|
};
|
|
378
368
|
await handler.handleToolCall(event1, ctx);
|
|
379
|
-
expect(
|
|
369
|
+
expect(prompter.prompt).toHaveBeenCalledTimes(1);
|
|
380
370
|
|
|
381
371
|
// Second call — read from /tmp/out.txt (same directory, different tool)
|
|
382
372
|
const event2 = {
|
|
@@ -386,7 +376,7 @@ describe("external-directory session dedup", () => {
|
|
|
386
376
|
input: { path: "/tmp/out.txt" },
|
|
387
377
|
};
|
|
388
378
|
await handler.handleToolCall(event2, ctx);
|
|
389
|
-
expect(
|
|
379
|
+
expect(prompter.prompt).toHaveBeenCalledTimes(1);
|
|
390
380
|
});
|
|
391
381
|
});
|
|
392
382
|
});
|
|
@@ -81,9 +81,7 @@ describe("GateRunner — descriptor path", () => {
|
|
|
81
81
|
it("returns allow and emits user_approved when ask + user approves", async () => {
|
|
82
82
|
const { runner, deps } = makeGateRunner({
|
|
83
83
|
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
84
|
-
|
|
85
|
-
.fn()
|
|
86
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
84
|
+
prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
|
|
87
85
|
});
|
|
88
86
|
const result = await runner.run(makeDescriptor(), null, "tc-1");
|
|
89
87
|
expect(result).toEqual({ action: "allow" });
|
|
@@ -98,7 +96,7 @@ describe("GateRunner — descriptor path", () => {
|
|
|
98
96
|
it("returns allow, emits user_approved_for_session, and records session rule on approved_for_session", async () => {
|
|
99
97
|
const { runner, deps } = makeGateRunner({
|
|
100
98
|
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
101
|
-
|
|
99
|
+
prompt: vi
|
|
102
100
|
.fn()
|
|
103
101
|
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
104
102
|
});
|
|
@@ -120,7 +118,7 @@ describe("GateRunner — descriptor path", () => {
|
|
|
120
118
|
it("calls recordSessionApproval once with the full SessionApproval when sessionApproval has multiple patterns", async () => {
|
|
121
119
|
const { runner, deps } = makeGateRunner({
|
|
122
120
|
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
123
|
-
|
|
121
|
+
prompt: vi
|
|
124
122
|
.fn()
|
|
125
123
|
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
126
124
|
});
|
|
@@ -138,9 +136,7 @@ describe("GateRunner — descriptor path", () => {
|
|
|
138
136
|
it("returns block and emits user_denied when ask + user denies", async () => {
|
|
139
137
|
const { runner, deps } = makeGateRunner({
|
|
140
138
|
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
141
|
-
|
|
142
|
-
.fn()
|
|
143
|
-
.mockResolvedValue({ approved: false, state: "denied" }),
|
|
139
|
+
prompt: vi.fn().mockResolvedValue({ approved: false, state: "denied" }),
|
|
144
140
|
});
|
|
145
141
|
const result = await runner.run(makeDescriptor(), null, "tc-1");
|
|
146
142
|
expect(result).toMatchObject({ action: "block" });
|
|
@@ -170,7 +166,7 @@ describe("GateRunner — descriptor path", () => {
|
|
|
170
166
|
it("emits auto_approved resolution when decision has autoApproved flag", async () => {
|
|
171
167
|
const { runner, deps } = makeGateRunner({
|
|
172
168
|
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
173
|
-
|
|
169
|
+
prompt: vi.fn().mockResolvedValue({
|
|
174
170
|
approved: true,
|
|
175
171
|
state: "approved",
|
|
176
172
|
autoApproved: true,
|
|
@@ -227,12 +223,12 @@ describe("GateRunner — descriptor path", () => {
|
|
|
227
223
|
);
|
|
228
224
|
});
|
|
229
225
|
|
|
230
|
-
it("passes requestId from toolCallId to
|
|
226
|
+
it("passes requestId from toolCallId to prompt", async () => {
|
|
231
227
|
const { runner, deps } = makeGateRunner({
|
|
232
228
|
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
233
229
|
});
|
|
234
230
|
await runner.run(makeDescriptor(), null, "tc-42");
|
|
235
|
-
expect(deps.
|
|
231
|
+
expect(deps.prompt).toHaveBeenCalledWith(
|
|
236
232
|
expect.objectContaining({ requestId: "tc-42" }),
|
|
237
233
|
);
|
|
238
234
|
});
|
|
@@ -240,9 +236,7 @@ describe("GateRunner — descriptor path", () => {
|
|
|
240
236
|
it("does not call recordSessionApproval when user approves once (no sessionApproval)", async () => {
|
|
241
237
|
const { runner, deps } = makeGateRunner({
|
|
242
238
|
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
243
|
-
|
|
244
|
-
.fn()
|
|
245
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
239
|
+
prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
|
|
246
240
|
});
|
|
247
241
|
await runner.run(makeDescriptor(), null, "tc-1");
|
|
248
242
|
expect(deps.recordSessionApproval).not.toHaveBeenCalled();
|
|
@@ -272,7 +266,7 @@ describe("GateRunner — descriptor path", () => {
|
|
|
272
266
|
it("does not call recordSessionApproval when user approves for session but no sessionApproval on descriptor", async () => {
|
|
273
267
|
const { runner, deps } = makeGateRunner({
|
|
274
268
|
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
275
|
-
|
|
269
|
+
prompt: vi
|
|
276
270
|
.fn()
|
|
277
271
|
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
278
272
|
});
|
|
@@ -323,7 +317,7 @@ describe("GateRunner — descriptor path", () => {
|
|
|
323
317
|
it("uses denialContext to format userDeniedReason with extension tag", async () => {
|
|
324
318
|
const { runner } = makeGateRunner({
|
|
325
319
|
resolveResult: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
326
|
-
|
|
320
|
+
prompt: vi.fn().mockResolvedValue({
|
|
327
321
|
approved: false,
|
|
328
322
|
state: "denied",
|
|
329
323
|
denialReason: "too risky",
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { describe, expect, it, vi } from "vitest";
|
|
5
5
|
|
|
6
|
+
import type { GatePrompter } from "#src/gate-prompter";
|
|
6
7
|
import {
|
|
7
8
|
getDecisionEvents,
|
|
8
9
|
makeCheckResult,
|
|
@@ -70,8 +71,11 @@ describe("handleInput decision events — skill gate", () => {
|
|
|
70
71
|
const { handler, events } = makeHandler({
|
|
71
72
|
session: {
|
|
72
73
|
checkPermission: makeSkillCheckPermission("ask"),
|
|
74
|
+
},
|
|
75
|
+
prompter: {
|
|
76
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
73
77
|
prompt: vi
|
|
74
|
-
.fn()
|
|
78
|
+
.fn<GatePrompter["prompt"]>()
|
|
75
79
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
76
80
|
},
|
|
77
81
|
});
|
|
@@ -91,7 +95,12 @@ describe("handleInput decision events — skill gate", () => {
|
|
|
91
95
|
const { handler, events } = makeHandler({
|
|
92
96
|
session: {
|
|
93
97
|
checkPermission: makeSkillCheckPermission("ask"),
|
|
94
|
-
|
|
98
|
+
},
|
|
99
|
+
prompter: {
|
|
100
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
101
|
+
prompt: vi
|
|
102
|
+
.fn<GatePrompter["prompt"]>()
|
|
103
|
+
.mockResolvedValue({ approved: false, state: "denied" }),
|
|
95
104
|
},
|
|
96
105
|
});
|
|
97
106
|
await handler.handleInput({ text: "/skill:explorer" }, makeCtx());
|
|
@@ -110,7 +119,10 @@ describe("handleInput decision events — skill gate", () => {
|
|
|
110
119
|
const { handler, events } = makeHandler({
|
|
111
120
|
session: {
|
|
112
121
|
checkPermission: makeSkillCheckPermission("ask"),
|
|
113
|
-
|
|
122
|
+
},
|
|
123
|
+
prompter: {
|
|
124
|
+
canConfirm: vi.fn().mockReturnValue(false),
|
|
125
|
+
prompt: vi.fn<GatePrompter["prompt"]>(),
|
|
114
126
|
},
|
|
115
127
|
});
|
|
116
128
|
await handler.handleInput(
|
|
@@ -132,7 +144,10 @@ describe("handleInput decision events — skill gate", () => {
|
|
|
132
144
|
const { handler, events } = makeHandler({
|
|
133
145
|
session: {
|
|
134
146
|
checkPermission: makeSkillCheckPermission("ask"),
|
|
135
|
-
|
|
147
|
+
},
|
|
148
|
+
prompter: {
|
|
149
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
150
|
+
prompt: vi.fn<GatePrompter["prompt"]>().mockResolvedValue({
|
|
136
151
|
approved: true,
|
|
137
152
|
state: "approved",
|
|
138
153
|
autoApproved: true,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
2
|
+
import type { GatePrompter } from "#src/gate-prompter";
|
|
3
3
|
import { extractSkillNameFromInput } from "#src/handlers/permission-gate-handler";
|
|
4
4
|
|
|
5
5
|
import { makeCtx, makeHandler } from "#test/helpers/handler-fixtures";
|
|
@@ -120,7 +120,10 @@ describe("handleInput", () => {
|
|
|
120
120
|
const { handler } = makeHandler({
|
|
121
121
|
session: {
|
|
122
122
|
checkPermission: vi.fn().mockReturnValue({ state: "ask" }),
|
|
123
|
-
|
|
123
|
+
},
|
|
124
|
+
prompter: {
|
|
125
|
+
canConfirm: vi.fn().mockReturnValue(false),
|
|
126
|
+
prompt: vi.fn<GatePrompter["prompt"]>(),
|
|
124
127
|
},
|
|
125
128
|
});
|
|
126
129
|
const result = await handler.handleInput(
|
|
@@ -131,12 +134,16 @@ describe("handleInput", () => {
|
|
|
131
134
|
});
|
|
132
135
|
|
|
133
136
|
it("prompts and returns continue when skill ask is approved", async () => {
|
|
134
|
-
const
|
|
137
|
+
const approvePrompt = vi
|
|
138
|
+
.fn<GatePrompter["prompt"]>()
|
|
139
|
+
.mockResolvedValue({ approved: true, state: "approved" });
|
|
140
|
+
const { handler, prompter } = makeHandler({
|
|
135
141
|
session: {
|
|
136
142
|
checkPermission: vi.fn().mockReturnValue({ state: "ask" }),
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
143
|
+
},
|
|
144
|
+
prompter: {
|
|
145
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
146
|
+
prompt: approvePrompt,
|
|
140
147
|
},
|
|
141
148
|
});
|
|
142
149
|
const result = await handler.handleInput(
|
|
@@ -144,14 +151,19 @@ describe("handleInput", () => {
|
|
|
144
151
|
makeCtx(),
|
|
145
152
|
);
|
|
146
153
|
expect(result).toEqual({ action: "continue" });
|
|
147
|
-
expect(
|
|
154
|
+
expect(prompter.prompt).toHaveBeenCalledOnce();
|
|
148
155
|
});
|
|
149
156
|
|
|
150
157
|
it("returns handled when skill ask is denied by user", async () => {
|
|
151
158
|
const { handler } = makeHandler({
|
|
152
159
|
session: {
|
|
153
160
|
checkPermission: vi.fn().mockReturnValue({ state: "ask" }),
|
|
154
|
-
|
|
161
|
+
},
|
|
162
|
+
prompter: {
|
|
163
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
164
|
+
prompt: vi
|
|
165
|
+
.fn<GatePrompter["prompt"]>()
|
|
166
|
+
.mockResolvedValue({ approved: false, state: "denied" }),
|
|
155
167
|
},
|
|
156
168
|
});
|
|
157
169
|
const result = await handler.handleInput(
|
|
@@ -162,17 +174,21 @@ describe("handleInput", () => {
|
|
|
162
174
|
});
|
|
163
175
|
|
|
164
176
|
it("passes agentName in the prompt permission request", async () => {
|
|
165
|
-
const
|
|
177
|
+
const approvePrompt = vi
|
|
178
|
+
.fn<GatePrompter["prompt"]>()
|
|
179
|
+
.mockResolvedValue({ approved: true, state: "approved" });
|
|
180
|
+
const { handler, prompter } = makeHandler({
|
|
166
181
|
session: {
|
|
167
182
|
checkPermission: vi.fn().mockReturnValue({ state: "ask" }),
|
|
168
183
|
resolveAgentName: vi.fn().mockReturnValue("code-agent"),
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
184
|
+
},
|
|
185
|
+
prompter: {
|
|
186
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
187
|
+
prompt: approvePrompt,
|
|
172
188
|
},
|
|
173
189
|
});
|
|
174
190
|
await handler.handleInput(makeInputEvent("/skill:librarian"), makeCtx());
|
|
175
|
-
expect(
|
|
191
|
+
expect(prompter.prompt).toHaveBeenCalledWith(
|
|
176
192
|
expect.objectContaining({
|
|
177
193
|
agentName: "code-agent",
|
|
178
194
|
skillName: "librarian",
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { describe, expect, it, vi } from "vitest";
|
|
6
6
|
|
|
7
|
+
import type { GatePrompter } from "#src/gate-prompter";
|
|
7
8
|
import {
|
|
8
9
|
getDecisionEvents,
|
|
9
10
|
makeCheckResult,
|
|
@@ -110,8 +111,11 @@ describe("handleToolCall decision events — user_approved", () => {
|
|
|
110
111
|
checkPermission: vi
|
|
111
112
|
.fn()
|
|
112
113
|
.mockReturnValue(makeCheckResult({ state: "ask" })),
|
|
114
|
+
},
|
|
115
|
+
prompter: {
|
|
116
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
113
117
|
prompt: vi
|
|
114
|
-
.fn()
|
|
118
|
+
.fn<GatePrompter["prompt"]>()
|
|
115
119
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
116
120
|
},
|
|
117
121
|
});
|
|
@@ -132,7 +136,10 @@ describe("handleToolCall decision events — user_approved", () => {
|
|
|
132
136
|
checkPermission: vi
|
|
133
137
|
.fn()
|
|
134
138
|
.mockReturnValue(makeCheckResult({ state: "ask" })),
|
|
135
|
-
|
|
139
|
+
},
|
|
140
|
+
prompter: {
|
|
141
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
142
|
+
prompt: vi.fn<GatePrompter["prompt"]>().mockResolvedValue({
|
|
136
143
|
approved: true,
|
|
137
144
|
state: "approved_for_session",
|
|
138
145
|
}),
|
|
@@ -159,7 +166,12 @@ describe("handleToolCall decision events — user_denied", () => {
|
|
|
159
166
|
checkPermission: vi
|
|
160
167
|
.fn()
|
|
161
168
|
.mockReturnValue(makeCheckResult({ state: "ask" })),
|
|
162
|
-
|
|
169
|
+
},
|
|
170
|
+
prompter: {
|
|
171
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
172
|
+
prompt: vi
|
|
173
|
+
.fn<GatePrompter["prompt"]>()
|
|
174
|
+
.mockResolvedValue({ approved: false, state: "denied" }),
|
|
163
175
|
},
|
|
164
176
|
});
|
|
165
177
|
|
|
@@ -183,7 +195,10 @@ describe("handleToolCall decision events — confirmation_unavailable", () => {
|
|
|
183
195
|
checkPermission: vi
|
|
184
196
|
.fn()
|
|
185
197
|
.mockReturnValue(makeCheckResult({ state: "ask" })),
|
|
186
|
-
|
|
198
|
+
},
|
|
199
|
+
prompter: {
|
|
200
|
+
canConfirm: vi.fn().mockReturnValue(false),
|
|
201
|
+
prompt: vi.fn<GatePrompter["prompt"]>(),
|
|
187
202
|
},
|
|
188
203
|
});
|
|
189
204
|
|
|
@@ -239,7 +254,10 @@ describe("handleToolCall decision events — auto_approved", () => {
|
|
|
239
254
|
checkPermission: vi
|
|
240
255
|
.fn()
|
|
241
256
|
.mockReturnValue(makeCheckResult({ state: "ask" })),
|
|
242
|
-
|
|
257
|
+
},
|
|
258
|
+
prompter: {
|
|
259
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
260
|
+
prompt: vi.fn<GatePrompter["prompt"]>().mockResolvedValue({
|
|
243
261
|
approved: true,
|
|
244
262
|
state: "approved",
|
|
245
263
|
autoApproved: true,
|
|
@@ -94,7 +94,7 @@ export function makeGateRunner(
|
|
|
94
94
|
resolve?: PermissionResolver["resolve"];
|
|
95
95
|
recordSessionApproval?: SessionApprovalRecorder["recordSessionApproval"];
|
|
96
96
|
canConfirm?: GatePrompter["canConfirm"];
|
|
97
|
-
|
|
97
|
+
prompt?: GatePrompter["prompt"];
|
|
98
98
|
reporter?: Partial<DecisionReporter>;
|
|
99
99
|
} = {},
|
|
100
100
|
) {
|
|
@@ -112,15 +112,15 @@ export function makeGateRunner(
|
|
|
112
112
|
const canConfirm =
|
|
113
113
|
overrides.canConfirm ??
|
|
114
114
|
(vi.fn().mockReturnValue(true) as GatePrompter["canConfirm"]);
|
|
115
|
-
const
|
|
116
|
-
overrides.
|
|
115
|
+
const prompt =
|
|
116
|
+
overrides.prompt ??
|
|
117
117
|
vi
|
|
118
|
-
.fn<GatePrompter["
|
|
118
|
+
.fn<GatePrompter["prompt"]>()
|
|
119
119
|
.mockResolvedValue({ approved: true, state: "approved" });
|
|
120
120
|
const runner = new GateRunner(
|
|
121
121
|
{ resolve },
|
|
122
122
|
{ recordSessionApproval },
|
|
123
|
-
{ canConfirm,
|
|
123
|
+
{ canConfirm, prompt },
|
|
124
124
|
reporter,
|
|
125
125
|
);
|
|
126
126
|
return {
|
|
@@ -129,7 +129,7 @@ export function makeGateRunner(
|
|
|
129
129
|
resolve,
|
|
130
130
|
recordSessionApproval,
|
|
131
131
|
canConfirm,
|
|
132
|
-
|
|
132
|
+
prompt,
|
|
133
133
|
reporter,
|
|
134
134
|
},
|
|
135
135
|
};
|