@gotgenes/pi-permission-system 8.1.0 → 8.2.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.
- package/CHANGELOG.md +15 -0
- package/package.json +1 -1
- package/src/config-loader.ts +53 -46
- package/src/handlers/gates/bash-external-directory.ts +2 -4
- package/src/handlers/gates/bash-path-extractor.ts +135 -169
- package/src/handlers/gates/bash-path.ts +2 -4
- package/src/handlers/gates/bash-token-classification.ts +105 -0
- package/src/handlers/gates/descriptor.ts +6 -6
- package/src/handlers/gates/external-directory.ts +2 -4
- package/src/handlers/gates/helpers.ts +30 -1
- package/src/handlers/gates/path.ts +2 -4
- package/src/handlers/gates/runner.ts +29 -56
- package/src/handlers/gates/tool.ts +5 -4
- package/src/handlers/permission-gate-handler.ts +4 -3
- package/src/permission-manager.ts +6 -49
- package/src/permission-session.ts +3 -2
- package/src/scope-merge.ts +72 -0
- package/src/session-approval.ts +43 -0
- package/src/session-rules.ts +13 -0
- package/test/config-loader.test.ts +82 -0
- package/test/handlers/before-agent-start.test.ts +2 -20
- package/test/handlers/external-directory-integration.test.ts +44 -82
- package/test/handlers/external-directory-session-dedup.test.ts +17 -41
- package/test/handlers/gates/bash-external-directory.test.ts +11 -9
- package/test/handlers/gates/bash-path.test.ts +5 -26
- package/test/handlers/gates/bash-token-classification.test.ts +241 -0
- package/test/handlers/gates/external-directory.test.ts +2 -5
- package/test/handlers/gates/helpers.test.ts +81 -0
- package/test/handlers/gates/path.test.ts +5 -14
- package/test/handlers/gates/runner.test.ts +95 -113
- package/test/handlers/gates/tool.test.ts +2 -2
- package/test/handlers/input-events.test.ts +42 -95
- package/test/handlers/input.test.ts +3 -71
- package/test/handlers/lifecycle.test.ts +3 -20
- package/test/handlers/tool-call-events.test.ts +30 -127
- package/test/handlers/tool-call.test.ts +21 -110
- package/test/helpers/gate-fixtures.ts +105 -0
- package/test/helpers/handler-fixtures.ts +141 -0
- package/test/helpers/manager-harness.ts +51 -0
- package/test/permission-session.test.ts +7 -22
- package/test/permission-system.test.ts +4 -40
- package/test/scope-merge.test.ts +116 -0
- package/test/session-approval.test.ts +75 -0
- package/test/session-rules.test.ts +49 -0
|
@@ -2,75 +2,11 @@ import { describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import type { DenialContext } from "#src/denial-messages";
|
|
4
4
|
import { EXTENSION_TAG } from "#src/denial-messages";
|
|
5
|
-
import type {
|
|
6
|
-
GateDescriptor,
|
|
7
|
-
GateRunnerDeps,
|
|
8
|
-
} from "#src/handlers/gates/descriptor";
|
|
5
|
+
import type { GateDescriptor } from "#src/handlers/gates/descriptor";
|
|
9
6
|
import { runGateCheck } from "#src/handlers/gates/runner";
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
function makeDescriptor(
|
|
15
|
-
overrides: Partial<GateDescriptor> = {},
|
|
16
|
-
): GateDescriptor {
|
|
17
|
-
return {
|
|
18
|
-
surface: "read",
|
|
19
|
-
input: {},
|
|
20
|
-
denialContext: {
|
|
21
|
-
kind: "tool",
|
|
22
|
-
check: makeCheckResult("deny"),
|
|
23
|
-
},
|
|
24
|
-
promptDetails: {
|
|
25
|
-
source: "tool_call",
|
|
26
|
-
agentName: null,
|
|
27
|
-
message: "Allow tool 'read'?",
|
|
28
|
-
toolCallId: "tc-1",
|
|
29
|
-
toolName: "read",
|
|
30
|
-
},
|
|
31
|
-
logContext: {
|
|
32
|
-
source: "tool_call",
|
|
33
|
-
toolCallId: "tc-1",
|
|
34
|
-
toolName: "read",
|
|
35
|
-
},
|
|
36
|
-
decision: {
|
|
37
|
-
surface: "read",
|
|
38
|
-
value: "read",
|
|
39
|
-
},
|
|
40
|
-
...overrides,
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function makeCheckResult(
|
|
45
|
-
state: "allow" | "deny" | "ask",
|
|
46
|
-
overrides: Partial<PermissionCheckResult> = {},
|
|
47
|
-
): PermissionCheckResult {
|
|
48
|
-
return {
|
|
49
|
-
state,
|
|
50
|
-
toolName: "read",
|
|
51
|
-
source: "tool",
|
|
52
|
-
origin: "builtin",
|
|
53
|
-
matchedPattern: "*",
|
|
54
|
-
...overrides,
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function makeRunnerDeps(
|
|
59
|
-
overrides: Partial<GateRunnerDeps> = {},
|
|
60
|
-
): GateRunnerDeps {
|
|
61
|
-
return {
|
|
62
|
-
checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
|
|
63
|
-
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
64
|
-
approveSessionRule: vi.fn(),
|
|
65
|
-
writeReviewLog: vi.fn(),
|
|
66
|
-
emitDecision: vi.fn(),
|
|
67
|
-
canConfirm: vi.fn().mockReturnValue(true),
|
|
68
|
-
promptPermission: vi
|
|
69
|
-
.fn()
|
|
70
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
71
|
-
...overrides,
|
|
72
|
-
};
|
|
73
|
-
}
|
|
7
|
+
import { SessionApproval } from "#src/session-approval";
|
|
8
|
+
import { makeDescriptor, makeRunnerDeps } from "#test/helpers/gate-fixtures";
|
|
9
|
+
import { makeCheckResult } from "#test/helpers/handler-fixtures";
|
|
74
10
|
|
|
75
11
|
// ── tests ──────────────────────────────────────────────────────────────────
|
|
76
12
|
|
|
@@ -91,7 +27,11 @@ describe("runGateCheck", () => {
|
|
|
91
27
|
|
|
92
28
|
it("returns block and emits policy_deny when policy is deny", async () => {
|
|
93
29
|
const deps = makeRunnerDeps({
|
|
94
|
-
checkPermission: vi
|
|
30
|
+
checkPermission: vi
|
|
31
|
+
.fn()
|
|
32
|
+
.mockReturnValue(
|
|
33
|
+
makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
34
|
+
),
|
|
95
35
|
});
|
|
96
36
|
const result = await runGateCheck(makeDescriptor(), null, "tc-1", deps);
|
|
97
37
|
expect(result).toMatchObject({ action: "block" });
|
|
@@ -109,12 +49,11 @@ describe("runGateCheck", () => {
|
|
|
109
49
|
|
|
110
50
|
it("returns allow and emits session_approved on session hit", async () => {
|
|
111
51
|
const deps = makeRunnerDeps({
|
|
112
|
-
checkPermission: vi
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
matchedPattern: "git *",
|
|
116
|
-
|
|
117
|
-
),
|
|
52
|
+
checkPermission: vi
|
|
53
|
+
.fn()
|
|
54
|
+
.mockReturnValue(
|
|
55
|
+
makeCheckResult({ source: "session", matchedPattern: "git *" }),
|
|
56
|
+
),
|
|
118
57
|
});
|
|
119
58
|
const result = await runGateCheck(
|
|
120
59
|
makeDescriptor({
|
|
@@ -144,7 +83,11 @@ describe("runGateCheck", () => {
|
|
|
144
83
|
|
|
145
84
|
it("returns allow and emits user_approved when ask + user approves", async () => {
|
|
146
85
|
const deps = makeRunnerDeps({
|
|
147
|
-
checkPermission: vi
|
|
86
|
+
checkPermission: vi
|
|
87
|
+
.fn()
|
|
88
|
+
.mockReturnValue(
|
|
89
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
90
|
+
),
|
|
148
91
|
promptPermission: vi
|
|
149
92
|
.fn()
|
|
150
93
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
@@ -161,13 +104,17 @@ describe("runGateCheck", () => {
|
|
|
161
104
|
|
|
162
105
|
it("returns allow, emits user_approved_for_session, and records session rule on approved_for_session", async () => {
|
|
163
106
|
const deps = makeRunnerDeps({
|
|
164
|
-
checkPermission: vi
|
|
107
|
+
checkPermission: vi
|
|
108
|
+
.fn()
|
|
109
|
+
.mockReturnValue(
|
|
110
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
111
|
+
),
|
|
165
112
|
promptPermission: vi
|
|
166
113
|
.fn()
|
|
167
114
|
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
168
115
|
});
|
|
169
116
|
const descriptor = makeDescriptor({
|
|
170
|
-
sessionApproval:
|
|
117
|
+
sessionApproval: SessionApproval.single("read", "*"),
|
|
171
118
|
});
|
|
172
119
|
const result = await runGateCheck(descriptor, null, "tc-1", deps);
|
|
173
120
|
expect(result).toEqual({ action: "allow" });
|
|
@@ -176,38 +123,40 @@ describe("runGateCheck", () => {
|
|
|
176
123
|
resolution: "user_approved_for_session",
|
|
177
124
|
}),
|
|
178
125
|
);
|
|
179
|
-
expect(deps.
|
|
126
|
+
expect(deps.recordSessionApproval).toHaveBeenCalledWith(
|
|
127
|
+
SessionApproval.single("read", "*"),
|
|
128
|
+
);
|
|
180
129
|
});
|
|
181
130
|
|
|
182
|
-
it("calls
|
|
131
|
+
it("calls recordSessionApproval once with the full SessionApproval when sessionApproval has multiple patterns", async () => {
|
|
183
132
|
const deps = makeRunnerDeps({
|
|
184
|
-
checkPermission: vi
|
|
133
|
+
checkPermission: vi
|
|
134
|
+
.fn()
|
|
135
|
+
.mockReturnValue(
|
|
136
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
137
|
+
),
|
|
185
138
|
promptPermission: vi
|
|
186
139
|
.fn()
|
|
187
140
|
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
188
141
|
});
|
|
189
|
-
const
|
|
190
|
-
sessionApproval: {
|
|
191
|
-
surface: "external_directory",
|
|
192
|
-
patterns: ["/outside/a/*", "/outside/b/*"],
|
|
193
|
-
},
|
|
194
|
-
});
|
|
195
|
-
const result = await runGateCheck(descriptor, null, "tc-1", deps);
|
|
196
|
-
expect(result).toEqual({ action: "allow" });
|
|
197
|
-
expect(deps.approveSessionRule).toHaveBeenCalledTimes(2);
|
|
198
|
-
expect(deps.approveSessionRule).toHaveBeenCalledWith(
|
|
199
|
-
"external_directory",
|
|
142
|
+
const approval = SessionApproval.multiple("external_directory", [
|
|
200
143
|
"/outside/a/*",
|
|
201
|
-
);
|
|
202
|
-
expect(deps.approveSessionRule).toHaveBeenCalledWith(
|
|
203
|
-
"external_directory",
|
|
204
144
|
"/outside/b/*",
|
|
205
|
-
);
|
|
145
|
+
]);
|
|
146
|
+
const descriptor = makeDescriptor({ sessionApproval: approval });
|
|
147
|
+
const result = await runGateCheck(descriptor, null, "tc-1", deps);
|
|
148
|
+
expect(result).toEqual({ action: "allow" });
|
|
149
|
+
expect(deps.recordSessionApproval).toHaveBeenCalledTimes(1);
|
|
150
|
+
expect(deps.recordSessionApproval).toHaveBeenCalledWith(approval);
|
|
206
151
|
});
|
|
207
152
|
|
|
208
153
|
it("returns block and emits user_denied when ask + user denies", async () => {
|
|
209
154
|
const deps = makeRunnerDeps({
|
|
210
|
-
checkPermission: vi
|
|
155
|
+
checkPermission: vi
|
|
156
|
+
.fn()
|
|
157
|
+
.mockReturnValue(
|
|
158
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
159
|
+
),
|
|
211
160
|
promptPermission: vi
|
|
212
161
|
.fn()
|
|
213
162
|
.mockResolvedValue({ approved: false, state: "denied" }),
|
|
@@ -224,7 +173,11 @@ describe("runGateCheck", () => {
|
|
|
224
173
|
|
|
225
174
|
it("returns block and emits confirmation_unavailable when ask + no UI", async () => {
|
|
226
175
|
const deps = makeRunnerDeps({
|
|
227
|
-
checkPermission: vi
|
|
176
|
+
checkPermission: vi
|
|
177
|
+
.fn()
|
|
178
|
+
.mockReturnValue(
|
|
179
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
180
|
+
),
|
|
228
181
|
canConfirm: vi.fn().mockReturnValue(false),
|
|
229
182
|
});
|
|
230
183
|
const result = await runGateCheck(makeDescriptor(), null, "tc-1", deps);
|
|
@@ -239,7 +192,11 @@ describe("runGateCheck", () => {
|
|
|
239
192
|
|
|
240
193
|
it("emits auto_approved resolution when decision has autoApproved flag", async () => {
|
|
241
194
|
const deps = makeRunnerDeps({
|
|
242
|
-
checkPermission: vi
|
|
195
|
+
checkPermission: vi
|
|
196
|
+
.fn()
|
|
197
|
+
.mockReturnValue(
|
|
198
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
199
|
+
),
|
|
243
200
|
promptPermission: vi.fn().mockResolvedValue({
|
|
244
201
|
approved: true,
|
|
245
202
|
state: "approved",
|
|
@@ -309,7 +266,11 @@ describe("runGateCheck", () => {
|
|
|
309
266
|
|
|
310
267
|
it("passes requestId from toolCallId to promptPermission", async () => {
|
|
311
268
|
const deps = makeRunnerDeps({
|
|
312
|
-
checkPermission: vi
|
|
269
|
+
checkPermission: vi
|
|
270
|
+
.fn()
|
|
271
|
+
.mockReturnValue(
|
|
272
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
273
|
+
),
|
|
313
274
|
});
|
|
314
275
|
await runGateCheck(makeDescriptor(), null, "tc-42", deps);
|
|
315
276
|
expect(deps.promptPermission).toHaveBeenCalledWith(
|
|
@@ -317,21 +278,26 @@ describe("runGateCheck", () => {
|
|
|
317
278
|
);
|
|
318
279
|
});
|
|
319
280
|
|
|
320
|
-
it("does not call
|
|
281
|
+
it("does not call recordSessionApproval when user approves once (no sessionApproval)", async () => {
|
|
321
282
|
const deps = makeRunnerDeps({
|
|
322
|
-
checkPermission: vi
|
|
283
|
+
checkPermission: vi
|
|
284
|
+
.fn()
|
|
285
|
+
.mockReturnValue(
|
|
286
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
287
|
+
),
|
|
323
288
|
promptPermission: vi
|
|
324
289
|
.fn()
|
|
325
290
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
326
291
|
});
|
|
327
292
|
await runGateCheck(makeDescriptor(), null, "tc-1", deps);
|
|
328
|
-
expect(deps.
|
|
293
|
+
expect(deps.recordSessionApproval).not.toHaveBeenCalled();
|
|
329
294
|
});
|
|
330
295
|
|
|
331
296
|
it("uses preCheck result directly instead of calling checkPermission", async () => {
|
|
332
297
|
const deps = makeRunnerDeps();
|
|
333
298
|
const descriptor = makeDescriptor({
|
|
334
|
-
preCheck: makeCheckResult(
|
|
299
|
+
preCheck: makeCheckResult({
|
|
300
|
+
state: "deny",
|
|
335
301
|
origin: "global",
|
|
336
302
|
matchedPattern: "rm *",
|
|
337
303
|
}),
|
|
@@ -348,16 +314,20 @@ describe("runGateCheck", () => {
|
|
|
348
314
|
);
|
|
349
315
|
});
|
|
350
316
|
|
|
351
|
-
it("does not call
|
|
317
|
+
it("does not call recordSessionApproval when user approves for session but no sessionApproval on descriptor", async () => {
|
|
352
318
|
const deps = makeRunnerDeps({
|
|
353
|
-
checkPermission: vi
|
|
319
|
+
checkPermission: vi
|
|
320
|
+
.fn()
|
|
321
|
+
.mockReturnValue(
|
|
322
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
323
|
+
),
|
|
354
324
|
promptPermission: vi
|
|
355
325
|
.fn()
|
|
356
326
|
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
357
327
|
});
|
|
358
328
|
// No sessionApproval on descriptor
|
|
359
329
|
await runGateCheck(makeDescriptor(), null, "tc-1", deps);
|
|
360
|
-
expect(deps.
|
|
330
|
+
expect(deps.recordSessionApproval).not.toHaveBeenCalled();
|
|
361
331
|
});
|
|
362
332
|
|
|
363
333
|
describe("denialContext formatting", () => {
|
|
@@ -391,11 +361,15 @@ describe("runGateCheck", () => {
|
|
|
391
361
|
|
|
392
362
|
it("uses denialContext to format denyReason with extension tag", async () => {
|
|
393
363
|
const deps = makeRunnerDeps({
|
|
394
|
-
checkPermission: vi
|
|
364
|
+
checkPermission: vi
|
|
365
|
+
.fn()
|
|
366
|
+
.mockReturnValue(
|
|
367
|
+
makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
368
|
+
),
|
|
395
369
|
});
|
|
396
370
|
const ctx: DenialContext = {
|
|
397
371
|
kind: "tool",
|
|
398
|
-
check: makeCheckResult("deny"),
|
|
372
|
+
check: makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
399
373
|
agentName: "test-agent",
|
|
400
374
|
};
|
|
401
375
|
const result = await runGateCheck(
|
|
@@ -413,12 +387,16 @@ describe("runGateCheck", () => {
|
|
|
413
387
|
|
|
414
388
|
it("uses denialContext to format unavailableReason with extension tag", async () => {
|
|
415
389
|
const deps = makeRunnerDeps({
|
|
416
|
-
checkPermission: vi
|
|
390
|
+
checkPermission: vi
|
|
391
|
+
.fn()
|
|
392
|
+
.mockReturnValue(
|
|
393
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
394
|
+
),
|
|
417
395
|
canConfirm: vi.fn().mockReturnValue(false),
|
|
418
396
|
});
|
|
419
397
|
const ctx: DenialContext = {
|
|
420
398
|
kind: "tool",
|
|
421
|
-
check: makeCheckResult("ask"),
|
|
399
|
+
check: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
422
400
|
};
|
|
423
401
|
const result = await runGateCheck(
|
|
424
402
|
makeDenialContextDescriptor(ctx),
|
|
@@ -435,7 +413,11 @@ describe("runGateCheck", () => {
|
|
|
435
413
|
|
|
436
414
|
it("uses denialContext to format userDeniedReason with extension tag", async () => {
|
|
437
415
|
const deps = makeRunnerDeps({
|
|
438
|
-
checkPermission: vi
|
|
416
|
+
checkPermission: vi
|
|
417
|
+
.fn()
|
|
418
|
+
.mockReturnValue(
|
|
419
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
420
|
+
),
|
|
439
421
|
promptPermission: vi.fn().mockResolvedValue({
|
|
440
422
|
approved: false,
|
|
441
423
|
state: "denied",
|
|
@@ -444,7 +426,7 @@ describe("runGateCheck", () => {
|
|
|
444
426
|
});
|
|
445
427
|
const ctx: DenialContext = {
|
|
446
428
|
kind: "tool",
|
|
447
|
-
check: makeCheckResult("ask"),
|
|
429
|
+
check: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
448
430
|
};
|
|
449
431
|
const result = await runGateCheck(
|
|
450
432
|
makeDenialContextDescriptor(ctx),
|
|
@@ -142,8 +142,8 @@ describe("describeToolGate", () => {
|
|
|
142
142
|
makeFormatter(),
|
|
143
143
|
);
|
|
144
144
|
expect(desc.sessionApproval).toBeDefined();
|
|
145
|
-
expect(desc.sessionApproval
|
|
146
|
-
expect(desc.sessionApproval
|
|
145
|
+
expect(desc.sessionApproval?.surface).toBe("bash");
|
|
146
|
+
expect(desc.sessionApproval?.representativePattern).toBeDefined();
|
|
147
147
|
});
|
|
148
148
|
|
|
149
149
|
it("populates promptDetails with correct fields", () => {
|
|
@@ -1,99 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests that handleInput emits permissions:decision events for skill input gates.
|
|
3
3
|
*/
|
|
4
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
4
|
import { describe, expect, it, vi } from "vitest";
|
|
6
5
|
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
import {
|
|
7
|
+
getDecisionEvents,
|
|
8
|
+
makeCheckResult,
|
|
9
|
+
makeCtx,
|
|
10
|
+
makeHandler,
|
|
11
|
+
} from "#test/helpers/handler-fixtures";
|
|
12
12
|
|
|
13
13
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 makeSession(
|
|
42
|
-
state: "allow" | "deny" | "ask" = "allow",
|
|
43
|
-
overrides: Partial<Record<keyof PermissionSession, unknown>> = {},
|
|
44
|
-
): PermissionSession {
|
|
45
|
-
return {
|
|
46
|
-
logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
|
|
47
|
-
activate: vi.fn(),
|
|
48
|
-
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
49
|
-
checkPermission: vi.fn().mockReturnValue({
|
|
15
|
+
/** Build a checkPermission mock returning a skill-surface result. */
|
|
16
|
+
function makeSkillCheckPermission(state: "allow" | "deny" | "ask") {
|
|
17
|
+
return vi.fn().mockReturnValue(
|
|
18
|
+
makeCheckResult({
|
|
50
19
|
state,
|
|
51
20
|
toolName: "skill",
|
|
52
21
|
source: "skill",
|
|
53
22
|
origin: "global",
|
|
54
23
|
matchedPattern: "*",
|
|
55
24
|
}),
|
|
56
|
-
getToolPermission: vi.fn().mockReturnValue("allow"),
|
|
57
|
-
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
58
|
-
approveSessionRule: vi.fn(),
|
|
59
|
-
canPrompt: vi.fn().mockReturnValue(true),
|
|
60
|
-
prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
|
|
61
|
-
createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
|
|
62
|
-
...overrides,
|
|
63
|
-
} as unknown as PermissionSession;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function makeToolRegistry(): ToolRegistry {
|
|
67
|
-
return {
|
|
68
|
-
getAll: vi.fn().mockReturnValue([]),
|
|
69
|
-
setActive: vi.fn(),
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function makeHandler(
|
|
74
|
-
state: "allow" | "deny" | "ask" = "allow",
|
|
75
|
-
sessionOverrides: Partial<Record<keyof PermissionSession, unknown>> = {},
|
|
76
|
-
): {
|
|
77
|
-
handler: PermissionGateHandler;
|
|
78
|
-
events: ReturnType<typeof makeEvents>;
|
|
79
|
-
} {
|
|
80
|
-
const session = makeSession(state, sessionOverrides);
|
|
81
|
-
const events = makeEvents();
|
|
82
|
-
const handler = new PermissionGateHandler(
|
|
83
|
-
session,
|
|
84
|
-
events,
|
|
85
|
-
makeToolRegistry(),
|
|
86
25
|
);
|
|
87
|
-
return { handler, events };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** Extract all permissions:decision payloads from the events.emit mock. */
|
|
91
|
-
function getDecisionEvents(
|
|
92
|
-
events: ReturnType<typeof makeEvents>,
|
|
93
|
-
): PermissionDecisionEvent[] {
|
|
94
|
-
return events.emit.mock.calls
|
|
95
|
-
.filter(([channel]) => channel === PERMISSIONS_DECISION_CHANNEL)
|
|
96
|
-
.map(([, payload]) => payload as PermissionDecisionEvent);
|
|
97
26
|
}
|
|
98
27
|
|
|
99
28
|
// ── tests ──────────────────────────────────────────────────────────────────
|
|
@@ -106,7 +35,9 @@ describe("handleInput decision events — skill gate", () => {
|
|
|
106
35
|
});
|
|
107
36
|
|
|
108
37
|
it("emits allow with policy_allow for an allowed skill", async () => {
|
|
109
|
-
const { handler, events } = makeHandler(
|
|
38
|
+
const { handler, events } = makeHandler({
|
|
39
|
+
session: { checkPermission: makeSkillCheckPermission("allow") },
|
|
40
|
+
});
|
|
110
41
|
await handler.handleInput({ text: "/skill:librarian" }, makeCtx());
|
|
111
42
|
|
|
112
43
|
const decisions = getDecisionEvents(events);
|
|
@@ -120,7 +51,9 @@ describe("handleInput decision events — skill gate", () => {
|
|
|
120
51
|
});
|
|
121
52
|
|
|
122
53
|
it("emits deny with policy_deny for a denied skill", async () => {
|
|
123
|
-
const { handler, events } = makeHandler(
|
|
54
|
+
const { handler, events } = makeHandler({
|
|
55
|
+
session: { checkPermission: makeSkillCheckPermission("deny") },
|
|
56
|
+
});
|
|
124
57
|
await handler.handleInput({ text: "/skill:restricted" }, makeCtx());
|
|
125
58
|
|
|
126
59
|
const decisions = getDecisionEvents(events);
|
|
@@ -134,8 +67,13 @@ describe("handleInput decision events — skill gate", () => {
|
|
|
134
67
|
});
|
|
135
68
|
|
|
136
69
|
it("emits allow with user_approved when state=ask and user approves", async () => {
|
|
137
|
-
const { handler, events } = makeHandler(
|
|
138
|
-
|
|
70
|
+
const { handler, events } = makeHandler({
|
|
71
|
+
session: {
|
|
72
|
+
checkPermission: makeSkillCheckPermission("ask"),
|
|
73
|
+
prompt: vi
|
|
74
|
+
.fn()
|
|
75
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
76
|
+
},
|
|
139
77
|
});
|
|
140
78
|
await handler.handleInput({ text: "/skill:explorer" }, makeCtx());
|
|
141
79
|
|
|
@@ -150,8 +88,11 @@ describe("handleInput decision events — skill gate", () => {
|
|
|
150
88
|
});
|
|
151
89
|
|
|
152
90
|
it("emits deny with user_denied when state=ask and user denies", async () => {
|
|
153
|
-
const { handler, events } = makeHandler(
|
|
154
|
-
|
|
91
|
+
const { handler, events } = makeHandler({
|
|
92
|
+
session: {
|
|
93
|
+
checkPermission: makeSkillCheckPermission("ask"),
|
|
94
|
+
prompt: vi.fn().mockResolvedValue({ approved: false, state: "denied" }),
|
|
95
|
+
},
|
|
155
96
|
});
|
|
156
97
|
await handler.handleInput({ text: "/skill:explorer" }, makeCtx());
|
|
157
98
|
|
|
@@ -166,8 +107,11 @@ describe("handleInput decision events — skill gate", () => {
|
|
|
166
107
|
});
|
|
167
108
|
|
|
168
109
|
it("emits deny with confirmation_unavailable when state=ask but no UI", async () => {
|
|
169
|
-
const { handler, events } = makeHandler(
|
|
170
|
-
|
|
110
|
+
const { handler, events } = makeHandler({
|
|
111
|
+
session: {
|
|
112
|
+
checkPermission: makeSkillCheckPermission("ask"),
|
|
113
|
+
canPrompt: vi.fn().mockReturnValue(false),
|
|
114
|
+
},
|
|
171
115
|
});
|
|
172
116
|
await handler.handleInput(
|
|
173
117
|
{ text: "/skill:explorer" },
|
|
@@ -185,12 +129,15 @@ describe("handleInput decision events — skill gate", () => {
|
|
|
185
129
|
});
|
|
186
130
|
|
|
187
131
|
it("emits allow with auto_approved when prompt returns autoApproved:true", async () => {
|
|
188
|
-
const { handler, events } = makeHandler(
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
132
|
+
const { handler, events } = makeHandler({
|
|
133
|
+
session: {
|
|
134
|
+
checkPermission: makeSkillCheckPermission("ask"),
|
|
135
|
+
prompt: vi.fn().mockResolvedValue({
|
|
136
|
+
approved: true,
|
|
137
|
+
state: "approved",
|
|
138
|
+
autoApproved: true,
|
|
139
|
+
}),
|
|
140
|
+
},
|
|
194
141
|
});
|
|
195
142
|
await handler.handleInput({ text: "/skill:explorer" }, makeCtx());
|
|
196
143
|
|
|
@@ -1,83 +1,15 @@
|
|
|
1
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
1
|
import { describe, expect, it, vi } from "vitest";
|
|
3
2
|
|
|
4
|
-
import {
|
|
5
|
-
extractSkillNameFromInput,
|
|
6
|
-
PermissionGateHandler,
|
|
7
|
-
} from "#src/handlers/permission-gate-handler";
|
|
8
|
-
import type { PermissionSession } from "#src/permission-session";
|
|
9
|
-
import type { ToolRegistry } from "#src/tool-registry";
|
|
3
|
+
import { extractSkillNameFromInput } from "#src/handlers/permission-gate-handler";
|
|
10
4
|
|
|
11
|
-
|
|
5
|
+
import { makeCtx, makeHandler } from "#test/helpers/handler-fixtures";
|
|
12
6
|
|
|
13
|
-
|
|
14
|
-
return {
|
|
15
|
-
cwd: "/test/project",
|
|
16
|
-
hasUI: true,
|
|
17
|
-
ui: {
|
|
18
|
-
setStatus: vi.fn(),
|
|
19
|
-
notify: vi.fn(),
|
|
20
|
-
select: vi.fn(),
|
|
21
|
-
input: vi.fn(),
|
|
22
|
-
},
|
|
23
|
-
sessionManager: {
|
|
24
|
-
getEntries: vi.fn().mockReturnValue([]),
|
|
25
|
-
getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
|
|
26
|
-
addEntry: vi.fn(),
|
|
27
|
-
},
|
|
28
|
-
...overrides,
|
|
29
|
-
} as unknown as ExtensionContext;
|
|
30
|
-
}
|
|
7
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
31
8
|
|
|
32
9
|
function makeInputEvent(text: string) {
|
|
33
10
|
return { text };
|
|
34
11
|
}
|
|
35
12
|
|
|
36
|
-
function makeSession(
|
|
37
|
-
overrides: Partial<Record<keyof PermissionSession, unknown>> = {},
|
|
38
|
-
): PermissionSession {
|
|
39
|
-
return {
|
|
40
|
-
logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
|
|
41
|
-
activate: vi.fn(),
|
|
42
|
-
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
43
|
-
checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
|
|
44
|
-
getToolPermission: vi.fn().mockReturnValue("allow"),
|
|
45
|
-
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
46
|
-
approveSessionRule: vi.fn(),
|
|
47
|
-
canPrompt: vi.fn().mockReturnValue(true),
|
|
48
|
-
prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
|
|
49
|
-
createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
|
|
50
|
-
...overrides,
|
|
51
|
-
} as unknown as PermissionSession;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function makeEvents() {
|
|
55
|
-
return {
|
|
56
|
-
emit: vi.fn(),
|
|
57
|
-
on: vi.fn().mockReturnValue(() => undefined),
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function makeToolRegistry(): ToolRegistry {
|
|
62
|
-
return {
|
|
63
|
-
getAll: vi.fn().mockReturnValue([]),
|
|
64
|
-
setActive: vi.fn(),
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function makeHandler(overrides?: {
|
|
69
|
-
session?: Partial<Record<keyof PermissionSession, unknown>>;
|
|
70
|
-
}): {
|
|
71
|
-
handler: PermissionGateHandler;
|
|
72
|
-
session: PermissionSession;
|
|
73
|
-
} {
|
|
74
|
-
const session = makeSession(overrides?.session);
|
|
75
|
-
const events = makeEvents();
|
|
76
|
-
const toolRegistry = makeToolRegistry();
|
|
77
|
-
const handler = new PermissionGateHandler(session, events, toolRegistry);
|
|
78
|
-
return { handler, session };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
13
|
// ── extractSkillNameFromInput ──────────────────────────────────────────────
|
|
82
14
|
|
|
83
15
|
describe("extractSkillNameFromInput", () => {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
3
|
import { SessionLifecycleHandler } from "#src/handlers/lifecycle";
|
|
4
4
|
import type { PermissionSession } from "#src/permission-session";
|
|
5
5
|
|
|
6
|
+
import { makeCtx } from "#test/helpers/handler-fixtures";
|
|
7
|
+
|
|
6
8
|
// ── status stub ────────────────────────────────────────────────────────────
|
|
7
9
|
vi.mock("../../src/status", () => ({
|
|
8
10
|
PERMISSION_SYSTEM_STATUS_KEY: "permission-system",
|
|
@@ -12,25 +14,6 @@ vi.mock("../../src/status", () => ({
|
|
|
12
14
|
|
|
13
15
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
14
16
|
|
|
15
|
-
function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
|
|
16
|
-
return {
|
|
17
|
-
cwd: "/test/project",
|
|
18
|
-
hasUI: true,
|
|
19
|
-
ui: {
|
|
20
|
-
setStatus: vi.fn(),
|
|
21
|
-
notify: vi.fn(),
|
|
22
|
-
select: vi.fn(),
|
|
23
|
-
input: vi.fn(),
|
|
24
|
-
},
|
|
25
|
-
sessionManager: {
|
|
26
|
-
getEntries: vi.fn().mockReturnValue([]),
|
|
27
|
-
getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
|
|
28
|
-
addEntry: vi.fn(),
|
|
29
|
-
},
|
|
30
|
-
...overrides,
|
|
31
|
-
} as unknown as ExtensionContext;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
17
|
function makeSession(
|
|
35
18
|
overrides: Partial<Record<keyof PermissionSession, unknown>> = {},
|
|
36
19
|
): PermissionSession {
|