@gotgenes/pi-permission-system 10.3.1 → 10.5.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 +24 -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/bash-command.ts +2 -2
- package/src/handlers/gates/bash-external-directory.ts +2 -2
- package/src/handlers/gates/bash-path.ts +2 -2
- package/src/handlers/gates/path.ts +2 -2
- package/src/handlers/gates/runner.ts +3 -3
- package/src/handlers/gates/tool-call-gate-pipeline.ts +10 -9
- package/src/index.ts +27 -41
- package/src/permission-event-rpc.ts +19 -15
- package/src/permission-prompter.ts +4 -3
- package/src/permission-resolver.ts +69 -2
- package/src/permission-session.ts +7 -83
- 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 +47 -57
- package/test/handlers/gates/bash-external-directory.test.ts +2 -2
- package/test/handlers/gates/bash-path.test.ts +2 -2
- package/test/handlers/gates/runner.test.ts +10 -16
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +30 -21
- 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 +11 -15
- package/test/helpers/handler-fixtures.ts +31 -50
- 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-resolver.test.ts +194 -0
- package/test/permission-session.test.ts +27 -180
- package/test/prompting-gateway.test.ts +230 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { ScopedPermissionManager } from "#src/permission-manager";
|
|
3
|
+
import { PermissionResolver } from "#src/permission-resolver";
|
|
4
|
+
import type { Ruleset } from "#src/rule";
|
|
5
|
+
import { SessionApproval } from "#src/session-approval";
|
|
6
|
+
import { SessionRules } from "#src/session-rules";
|
|
7
|
+
import type { PermissionCheckResult, PermissionState } from "#src/types";
|
|
8
|
+
|
|
9
|
+
function makePermissionManager() {
|
|
10
|
+
return {
|
|
11
|
+
configureForCwd: vi.fn<(cwd: string | undefined | null) => void>(),
|
|
12
|
+
checkPermission: vi
|
|
13
|
+
.fn<
|
|
14
|
+
(
|
|
15
|
+
toolName: string,
|
|
16
|
+
input: unknown,
|
|
17
|
+
agentName?: string,
|
|
18
|
+
sessionRules?: Ruleset,
|
|
19
|
+
) => PermissionCheckResult
|
|
20
|
+
>()
|
|
21
|
+
.mockReturnValue({
|
|
22
|
+
state: "allow",
|
|
23
|
+
toolName: "read",
|
|
24
|
+
source: "tool",
|
|
25
|
+
origin: "builtin",
|
|
26
|
+
}),
|
|
27
|
+
getToolPermission: vi
|
|
28
|
+
.fn<(toolName: string, agentName?: string) => PermissionState>()
|
|
29
|
+
.mockReturnValue("allow"),
|
|
30
|
+
getConfigIssues: vi.fn((): string[] => []),
|
|
31
|
+
getPolicyCacheStamp: vi.fn((): string => "stamp-1"),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeResolver(
|
|
36
|
+
pm?: ScopedPermissionManager,
|
|
37
|
+
sessionRules?: Pick<SessionRules, "getRuleset">,
|
|
38
|
+
) {
|
|
39
|
+
const permissionManager = pm ?? makePermissionManager();
|
|
40
|
+
const rules = sessionRules ?? new SessionRules();
|
|
41
|
+
return {
|
|
42
|
+
resolver: new PermissionResolver(permissionManager, rules),
|
|
43
|
+
permissionManager,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
// no module-level vi.fn() stubs to reset
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("PermissionResolver", () => {
|
|
52
|
+
describe("resolve", () => {
|
|
53
|
+
it("forwards surface, input, and agentName, applying the empty session ruleset", () => {
|
|
54
|
+
const { resolver, permissionManager } = makeResolver();
|
|
55
|
+
|
|
56
|
+
resolver.resolve("bash", { command: "ls" }, "agent-x");
|
|
57
|
+
|
|
58
|
+
expect(permissionManager.checkPermission).toHaveBeenCalledWith(
|
|
59
|
+
"bash",
|
|
60
|
+
{ command: "ls" },
|
|
61
|
+
"agent-x",
|
|
62
|
+
[],
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("defaults agentName to undefined when omitted", () => {
|
|
67
|
+
const { resolver, permissionManager } = makeResolver();
|
|
68
|
+
|
|
69
|
+
resolver.resolve("read", { path: ".env" });
|
|
70
|
+
|
|
71
|
+
expect(permissionManager.checkPermission).toHaveBeenCalledWith(
|
|
72
|
+
"read",
|
|
73
|
+
{ path: ".env" },
|
|
74
|
+
undefined,
|
|
75
|
+
[],
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("applies a recorded session approval on the next resolve", () => {
|
|
80
|
+
const pm = makePermissionManager();
|
|
81
|
+
const sessionRules = new SessionRules();
|
|
82
|
+
const { resolver } = makeResolver(pm, sessionRules);
|
|
83
|
+
|
|
84
|
+
// Record an approval directly into the shared SessionRules instance.
|
|
85
|
+
sessionRules.record(SessionApproval.single("bash", "git *"));
|
|
86
|
+
resolver.resolve("bash", { command: "git status" });
|
|
87
|
+
|
|
88
|
+
const passedRules = vi.mocked(pm.checkPermission).mock.calls[0][3];
|
|
89
|
+
expect(passedRules).toHaveLength(1);
|
|
90
|
+
expect(passedRules?.[0]).toMatchObject({
|
|
91
|
+
surface: "bash",
|
|
92
|
+
pattern: "git *",
|
|
93
|
+
action: "allow",
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns the PermissionManager's check result", () => {
|
|
98
|
+
const pm = makePermissionManager();
|
|
99
|
+
vi.mocked(pm.checkPermission).mockReturnValue({
|
|
100
|
+
state: "deny",
|
|
101
|
+
toolName: "bash",
|
|
102
|
+
source: "bash",
|
|
103
|
+
origin: "global",
|
|
104
|
+
matchedPattern: "rm *",
|
|
105
|
+
});
|
|
106
|
+
const { resolver } = makeResolver(pm);
|
|
107
|
+
|
|
108
|
+
const result = resolver.resolve("bash", { command: "rm -rf /" });
|
|
109
|
+
|
|
110
|
+
expect(result).toEqual({
|
|
111
|
+
state: "deny",
|
|
112
|
+
toolName: "bash",
|
|
113
|
+
source: "bash",
|
|
114
|
+
origin: "global",
|
|
115
|
+
matchedPattern: "rm *",
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("checkPermission", () => {
|
|
121
|
+
it("delegates to permissionManager.checkPermission with the given args", () => {
|
|
122
|
+
const { resolver, permissionManager } = makeResolver();
|
|
123
|
+
|
|
124
|
+
resolver.checkPermission("bash", { command: "ls" }, "agent-1");
|
|
125
|
+
|
|
126
|
+
expect(permissionManager.checkPermission).toHaveBeenCalledWith(
|
|
127
|
+
"bash",
|
|
128
|
+
{ command: "ls" },
|
|
129
|
+
"agent-1",
|
|
130
|
+
undefined,
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("passes optional sessionRules through when supplied", () => {
|
|
135
|
+
const { resolver, permissionManager } = makeResolver();
|
|
136
|
+
const extraRules: Ruleset = [
|
|
137
|
+
{ surface: "bash", pattern: "*", action: "allow", origin: "session" },
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
resolver.checkPermission(
|
|
141
|
+
"bash",
|
|
142
|
+
{ command: "ls" },
|
|
143
|
+
undefined,
|
|
144
|
+
extraRules,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
expect(permissionManager.checkPermission).toHaveBeenCalledWith(
|
|
148
|
+
"bash",
|
|
149
|
+
{ command: "ls" },
|
|
150
|
+
undefined,
|
|
151
|
+
extraRules,
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("getToolPermission", () => {
|
|
157
|
+
it("delegates to permissionManager.getToolPermission", () => {
|
|
158
|
+
const pm = makePermissionManager();
|
|
159
|
+
vi.mocked(pm.getToolPermission).mockReturnValue("deny");
|
|
160
|
+
const { resolver } = makeResolver(pm);
|
|
161
|
+
|
|
162
|
+
const result = resolver.getToolPermission("write", "my-agent");
|
|
163
|
+
|
|
164
|
+
expect(pm.getToolPermission).toHaveBeenCalledWith("write", "my-agent");
|
|
165
|
+
expect(result).toBe("deny");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("getConfigIssues", () => {
|
|
170
|
+
it("delegates to permissionManager.getConfigIssues", () => {
|
|
171
|
+
const pm = makePermissionManager();
|
|
172
|
+
vi.mocked(pm.getConfigIssues).mockReturnValue(["issue-1"]);
|
|
173
|
+
const { resolver } = makeResolver(pm);
|
|
174
|
+
|
|
175
|
+
const result = resolver.getConfigIssues("agent-1");
|
|
176
|
+
|
|
177
|
+
expect(pm.getConfigIssues).toHaveBeenCalledWith("agent-1");
|
|
178
|
+
expect(result).toEqual(["issue-1"]);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("getPolicyCacheStamp", () => {
|
|
183
|
+
it("delegates to permissionManager.getPolicyCacheStamp", () => {
|
|
184
|
+
const pm = makePermissionManager();
|
|
185
|
+
vi.mocked(pm.getPolicyCacheStamp).mockReturnValue("stamp-abc");
|
|
186
|
+
const { resolver } = makeResolver(pm);
|
|
187
|
+
|
|
188
|
+
const result = resolver.getPolicyCacheStamp("agent-1");
|
|
189
|
+
|
|
190
|
+
expect(pm.getPolicyCacheStamp).toHaveBeenCalledWith("agent-1");
|
|
191
|
+
expect(result).toBe("stamp-abc");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -22,10 +22,8 @@ import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
|
22
22
|
import type { ExtensionPaths } from "#src/extension-paths";
|
|
23
23
|
import type { ForwardingController } from "#src/forwarding-manager";
|
|
24
24
|
import type { ScopedPermissionManager } from "#src/permission-manager";
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
type PermissionSessionRuntimeDeps,
|
|
28
|
-
} from "#src/permission-session";
|
|
25
|
+
import { PermissionSession } from "#src/permission-session";
|
|
26
|
+
import type { PromptingGatewayLifecycle } from "#src/prompting-gateway";
|
|
29
27
|
import type { Ruleset } from "#src/rule";
|
|
30
28
|
import { SessionApproval } from "#src/session-approval";
|
|
31
29
|
import type { SessionLogger } from "#src/session-logger";
|
|
@@ -83,12 +81,10 @@ function makeConfigStore(
|
|
|
83
81
|
};
|
|
84
82
|
}
|
|
85
83
|
|
|
86
|
-
function
|
|
84
|
+
function makeGateway(): PromptingGatewayLifecycle {
|
|
87
85
|
return {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
.fn()
|
|
91
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
86
|
+
activate: vi.fn<PromptingGatewayLifecycle["activate"]>(),
|
|
87
|
+
deactivate: vi.fn<PromptingGatewayLifecycle["deactivate"]>(),
|
|
92
88
|
};
|
|
93
89
|
}
|
|
94
90
|
|
|
@@ -132,7 +128,7 @@ function createSession(overrides?: {
|
|
|
132
128
|
permissionManager?: ScopedPermissionManager;
|
|
133
129
|
sessionRules?: SessionRules;
|
|
134
130
|
configStore?: SessionConfigStore;
|
|
135
|
-
|
|
131
|
+
gateway?: PromptingGatewayLifecycle;
|
|
136
132
|
}): {
|
|
137
133
|
session: PermissionSession;
|
|
138
134
|
paths: ExtensionPaths;
|
|
@@ -140,7 +136,7 @@ function createSession(overrides?: {
|
|
|
140
136
|
forwarding: ForwardingController;
|
|
141
137
|
sessionRules: SessionRules;
|
|
142
138
|
configStore: SessionConfigStore;
|
|
143
|
-
|
|
139
|
+
gateway: PromptingGatewayLifecycle;
|
|
144
140
|
} {
|
|
145
141
|
const paths = makePaths(overrides?.paths);
|
|
146
142
|
const logger = overrides?.logger ?? makeLogger();
|
|
@@ -149,7 +145,7 @@ function createSession(overrides?: {
|
|
|
149
145
|
overrides?.permissionManager ?? makePermissionManager();
|
|
150
146
|
const sessionRules = overrides?.sessionRules ?? new SessionRules();
|
|
151
147
|
const configStore = overrides?.configStore ?? makeConfigStore();
|
|
152
|
-
const
|
|
148
|
+
const gateway = overrides?.gateway ?? makeGateway();
|
|
153
149
|
const session = new PermissionSession(
|
|
154
150
|
paths,
|
|
155
151
|
logger,
|
|
@@ -157,7 +153,7 @@ function createSession(overrides?: {
|
|
|
157
153
|
permissionManager,
|
|
158
154
|
sessionRules,
|
|
159
155
|
configStore,
|
|
160
|
-
|
|
156
|
+
gateway,
|
|
161
157
|
);
|
|
162
158
|
return {
|
|
163
159
|
session,
|
|
@@ -166,7 +162,7 @@ function createSession(overrides?: {
|
|
|
166
162
|
forwarding,
|
|
167
163
|
sessionRules,
|
|
168
164
|
configStore,
|
|
169
|
-
|
|
165
|
+
gateway,
|
|
170
166
|
};
|
|
171
167
|
}
|
|
172
168
|
|
|
@@ -244,74 +240,6 @@ describe("PermissionSession", () => {
|
|
|
244
240
|
});
|
|
245
241
|
});
|
|
246
242
|
|
|
247
|
-
describe("resolve", () => {
|
|
248
|
-
it("forwards surface, input, and agentName, applying the empty session ruleset", () => {
|
|
249
|
-
const pm = makePermissionManager();
|
|
250
|
-
const { session } = createSession({ permissionManager: pm });
|
|
251
|
-
|
|
252
|
-
session.resolve("bash", { command: "ls" }, "agent-x");
|
|
253
|
-
|
|
254
|
-
expect(pm.checkPermission).toHaveBeenCalledWith(
|
|
255
|
-
"bash",
|
|
256
|
-
{ command: "ls" },
|
|
257
|
-
"agent-x",
|
|
258
|
-
[],
|
|
259
|
-
);
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
it("defaults agentName to undefined when omitted", () => {
|
|
263
|
-
const pm = makePermissionManager();
|
|
264
|
-
const { session } = createSession({ permissionManager: pm });
|
|
265
|
-
|
|
266
|
-
session.resolve("read", { path: ".env" });
|
|
267
|
-
|
|
268
|
-
expect(pm.checkPermission).toHaveBeenCalledWith(
|
|
269
|
-
"read",
|
|
270
|
-
{ path: ".env" },
|
|
271
|
-
undefined,
|
|
272
|
-
[],
|
|
273
|
-
);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
it("applies a recorded session approval on the next resolve", () => {
|
|
277
|
-
const pm = makePermissionManager();
|
|
278
|
-
const { session } = createSession({ permissionManager: pm });
|
|
279
|
-
|
|
280
|
-
session.recordSessionApproval(SessionApproval.single("bash", "git *"));
|
|
281
|
-
session.resolve("bash", { command: "git status" });
|
|
282
|
-
|
|
283
|
-
const sessionRules = vi.mocked(pm.checkPermission).mock.calls[0][3];
|
|
284
|
-
expect(sessionRules).toHaveLength(1);
|
|
285
|
-
expect(sessionRules?.[0]).toMatchObject({
|
|
286
|
-
surface: "bash",
|
|
287
|
-
pattern: "git *",
|
|
288
|
-
action: "allow",
|
|
289
|
-
});
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
it("returns the PermissionManager's check result", () => {
|
|
293
|
-
const pm = makePermissionManager();
|
|
294
|
-
vi.mocked(pm.checkPermission).mockReturnValue({
|
|
295
|
-
state: "deny",
|
|
296
|
-
toolName: "bash",
|
|
297
|
-
source: "bash",
|
|
298
|
-
origin: "global",
|
|
299
|
-
matchedPattern: "rm *",
|
|
300
|
-
});
|
|
301
|
-
const { session } = createSession({ permissionManager: pm });
|
|
302
|
-
|
|
303
|
-
const result = session.resolve("bash", { command: "rm -rf /" });
|
|
304
|
-
|
|
305
|
-
expect(result).toEqual({
|
|
306
|
-
state: "deny",
|
|
307
|
-
toolName: "bash",
|
|
308
|
-
source: "bash",
|
|
309
|
-
origin: "global",
|
|
310
|
-
matchedPattern: "rm *",
|
|
311
|
-
});
|
|
312
|
-
});
|
|
313
|
-
});
|
|
314
|
-
|
|
315
243
|
describe("activate and deactivate", () => {
|
|
316
244
|
it("stores the context on activate", () => {
|
|
317
245
|
const { session, forwarding } = createSession();
|
|
@@ -329,6 +257,23 @@ describe("PermissionSession", () => {
|
|
|
329
257
|
|
|
330
258
|
expect(forwarding.stop).toHaveBeenCalled();
|
|
331
259
|
});
|
|
260
|
+
|
|
261
|
+
it("forwards activate to the gateway", () => {
|
|
262
|
+
const { session, gateway } = createSession();
|
|
263
|
+
const ctx = makeCtx();
|
|
264
|
+
|
|
265
|
+
session.activate(ctx);
|
|
266
|
+
|
|
267
|
+
expect(gateway.activate).toHaveBeenCalledWith(ctx);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("forwards deactivate to the gateway", () => {
|
|
271
|
+
const { session, gateway } = createSession();
|
|
272
|
+
session.activate(makeCtx());
|
|
273
|
+
session.deactivate();
|
|
274
|
+
|
|
275
|
+
expect(gateway.deactivate).toHaveBeenCalled();
|
|
276
|
+
});
|
|
332
277
|
});
|
|
333
278
|
|
|
334
279
|
describe("resetForNewSession", () => {
|
|
@@ -627,102 +572,4 @@ describe("PermissionSession", () => {
|
|
|
627
572
|
expect(session.getRuntimeContext()).toBeNull();
|
|
628
573
|
});
|
|
629
574
|
});
|
|
630
|
-
|
|
631
|
-
describe("canConfirm", () => {
|
|
632
|
-
it("returns true when context is active and canPrompt returns true", () => {
|
|
633
|
-
const { session } = createSession();
|
|
634
|
-
session.activate(makeCtx());
|
|
635
|
-
expect(session.canConfirm()).toBe(true);
|
|
636
|
-
});
|
|
637
|
-
|
|
638
|
-
it("returns false when no context is active", () => {
|
|
639
|
-
const { session } = createSession();
|
|
640
|
-
expect(session.canConfirm()).toBe(false);
|
|
641
|
-
});
|
|
642
|
-
|
|
643
|
-
it("returns false when canPrompt returns false", () => {
|
|
644
|
-
const runtimeDeps = makeRuntimeDeps();
|
|
645
|
-
(
|
|
646
|
-
runtimeDeps.canRequestPermissionConfirmation as ReturnType<typeof vi.fn>
|
|
647
|
-
).mockReturnValue(false);
|
|
648
|
-
const { session } = createSession({ runtimeDeps });
|
|
649
|
-
session.activate(makeCtx());
|
|
650
|
-
expect(session.canConfirm()).toBe(false);
|
|
651
|
-
});
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
describe("promptPermission", () => {
|
|
655
|
-
it("delegates to prompt with stored context", async () => {
|
|
656
|
-
const { session, runtimeDeps } = createSession();
|
|
657
|
-
const ctx = makeCtx();
|
|
658
|
-
session.activate(ctx);
|
|
659
|
-
const details = {
|
|
660
|
-
requestId: "req-1",
|
|
661
|
-
source: "tool_call" as const,
|
|
662
|
-
agentName: null,
|
|
663
|
-
message: "Allow?",
|
|
664
|
-
};
|
|
665
|
-
|
|
666
|
-
const result = await session.promptPermission(details);
|
|
667
|
-
|
|
668
|
-
expect(runtimeDeps.promptPermission).toHaveBeenCalledWith(ctx, details);
|
|
669
|
-
expect(result).toEqual({ approved: true, state: "approved" });
|
|
670
|
-
});
|
|
671
|
-
|
|
672
|
-
it("throws when no context is active", async () => {
|
|
673
|
-
const { session } = createSession();
|
|
674
|
-
const details = {
|
|
675
|
-
requestId: "req-1",
|
|
676
|
-
source: "tool_call" as const,
|
|
677
|
-
agentName: null,
|
|
678
|
-
message: "Allow?",
|
|
679
|
-
};
|
|
680
|
-
|
|
681
|
-
await expect(session.promptPermission(details)).rejects.toThrow(
|
|
682
|
-
"promptPermission called before the session was activated",
|
|
683
|
-
);
|
|
684
|
-
});
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
describe("canPrompt", () => {
|
|
688
|
-
it("delegates to runtimeDeps.canRequestPermissionConfirmation", () => {
|
|
689
|
-
const { session, runtimeDeps } = createSession();
|
|
690
|
-
const ctx = makeCtx();
|
|
691
|
-
|
|
692
|
-
const result = session.canPrompt(ctx);
|
|
693
|
-
|
|
694
|
-
expect(runtimeDeps.canRequestPermissionConfirmation).toHaveBeenCalledWith(
|
|
695
|
-
ctx,
|
|
696
|
-
);
|
|
697
|
-
expect(result).toBe(true);
|
|
698
|
-
});
|
|
699
|
-
|
|
700
|
-
it("returns false when runtimeDeps says no", () => {
|
|
701
|
-
const runtimeDeps = makeRuntimeDeps();
|
|
702
|
-
(
|
|
703
|
-
runtimeDeps.canRequestPermissionConfirmation as ReturnType<typeof vi.fn>
|
|
704
|
-
).mockReturnValue(false);
|
|
705
|
-
const { session } = createSession({ runtimeDeps });
|
|
706
|
-
|
|
707
|
-
expect(session.canPrompt(makeCtx())).toBe(false);
|
|
708
|
-
});
|
|
709
|
-
});
|
|
710
|
-
|
|
711
|
-
describe("prompt", () => {
|
|
712
|
-
it("delegates to runtimeDeps.promptPermission", async () => {
|
|
713
|
-
const { session, runtimeDeps } = createSession();
|
|
714
|
-
const ctx = makeCtx();
|
|
715
|
-
const details = {
|
|
716
|
-
requestId: "req-1",
|
|
717
|
-
source: "tool_call" as const,
|
|
718
|
-
agentName: null,
|
|
719
|
-
message: "Allow?",
|
|
720
|
-
};
|
|
721
|
-
|
|
722
|
-
const result = await session.prompt(ctx, details);
|
|
723
|
-
|
|
724
|
-
expect(runtimeDeps.promptPermission).toHaveBeenCalledWith(ctx, details);
|
|
725
|
-
expect(result).toEqual({ approved: true, state: "approved" });
|
|
726
|
-
});
|
|
727
|
-
});
|
|
728
575
|
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for PromptingGateway.
|
|
3
|
+
*
|
|
4
|
+
* The gateway owns the stored ExtensionContext and is the sole implementation
|
|
5
|
+
* of the GatePrompter role. These tests exercise canConfirm() across all
|
|
6
|
+
* policy permutations and verify the prompt/reject contract for promptPermission().
|
|
7
|
+
*/
|
|
8
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
|
|
11
|
+
import type { ConfigReader } from "#src/config-store";
|
|
12
|
+
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
13
|
+
import type { PermissionPromptDecision } from "#src/permission-dialog";
|
|
14
|
+
import type {
|
|
15
|
+
PermissionPrompterApi,
|
|
16
|
+
PromptPermissionDetails,
|
|
17
|
+
} from "#src/permission-prompter";
|
|
18
|
+
import {
|
|
19
|
+
PromptingGateway,
|
|
20
|
+
type PromptingGatewayDeps,
|
|
21
|
+
} from "#src/prompting-gateway";
|
|
22
|
+
|
|
23
|
+
// ── Test helpers ──────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
|
|
26
|
+
return {
|
|
27
|
+
cwd: "/test/project",
|
|
28
|
+
hasUI: true,
|
|
29
|
+
ui: {
|
|
30
|
+
setStatus: vi.fn(),
|
|
31
|
+
notify: vi.fn(),
|
|
32
|
+
select: vi.fn(),
|
|
33
|
+
input: vi.fn(),
|
|
34
|
+
},
|
|
35
|
+
sessionManager: {
|
|
36
|
+
getEntries: vi.fn().mockReturnValue([]),
|
|
37
|
+
getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
|
|
38
|
+
getSessionId: vi.fn().mockReturnValue(null),
|
|
39
|
+
addEntry: vi.fn(),
|
|
40
|
+
},
|
|
41
|
+
...overrides,
|
|
42
|
+
} as unknown as ExtensionContext;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeConfigReader(
|
|
46
|
+
overrides: Partial<typeof DEFAULT_EXTENSION_CONFIG> = {},
|
|
47
|
+
): ConfigReader {
|
|
48
|
+
return {
|
|
49
|
+
current: vi
|
|
50
|
+
.fn<() => typeof DEFAULT_EXTENSION_CONFIG>()
|
|
51
|
+
.mockReturnValue({ ...DEFAULT_EXTENSION_CONFIG, ...overrides }),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makePrompterApi(): PermissionPrompterApi & {
|
|
56
|
+
prompt: ReturnType<typeof vi.fn>;
|
|
57
|
+
} {
|
|
58
|
+
return {
|
|
59
|
+
prompt: vi
|
|
60
|
+
.fn<PermissionPrompterApi["prompt"]>()
|
|
61
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeDetails(): PromptPermissionDetails {
|
|
66
|
+
return {
|
|
67
|
+
requestId: "req-1",
|
|
68
|
+
source: "tool_call",
|
|
69
|
+
agentName: null,
|
|
70
|
+
message: "Allow this?",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function makeDeps(
|
|
75
|
+
overrides: Partial<PromptingGatewayDeps> = {},
|
|
76
|
+
): PromptingGatewayDeps {
|
|
77
|
+
return {
|
|
78
|
+
config: overrides.config ?? makeConfigReader(),
|
|
79
|
+
subagentSessionsDir:
|
|
80
|
+
overrides.subagentSessionsDir ?? "/test/agent/subagent-sessions",
|
|
81
|
+
registry: overrides.registry,
|
|
82
|
+
prompter: overrides.prompter ?? makePrompterApi(),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Tests ─────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
describe("PromptingGateway", () => {
|
|
89
|
+
describe("canConfirm", () => {
|
|
90
|
+
it("returns false before activate", () => {
|
|
91
|
+
const gateway = new PromptingGateway(makeDeps());
|
|
92
|
+
expect(gateway.canConfirm()).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns true after activate when context has UI", () => {
|
|
96
|
+
const gateway = new PromptingGateway(makeDeps());
|
|
97
|
+
gateway.activate(makeCtx({ hasUI: true }));
|
|
98
|
+
expect(gateway.canConfirm()).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("returns false when context has no UI, is not a subagent, and yolo mode is off", () => {
|
|
102
|
+
const gateway = new PromptingGateway(
|
|
103
|
+
makeDeps({ config: makeConfigReader({ yoloMode: false }) }),
|
|
104
|
+
);
|
|
105
|
+
gateway.activate(makeCtx({ hasUI: false }));
|
|
106
|
+
expect(gateway.canConfirm()).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns true when yolo mode is enabled (no UI, not subagent)", () => {
|
|
110
|
+
const gateway = new PromptingGateway(
|
|
111
|
+
makeDeps({ config: makeConfigReader({ yoloMode: true }) }),
|
|
112
|
+
);
|
|
113
|
+
gateway.activate(makeCtx({ hasUI: false }));
|
|
114
|
+
expect(gateway.canConfirm()).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("returns true when running as a subagent (env hint)", () => {
|
|
118
|
+
vi.stubEnv("PI_IS_SUBAGENT", "1");
|
|
119
|
+
const gateway = new PromptingGateway(
|
|
120
|
+
makeDeps({ config: makeConfigReader({ yoloMode: false }) }),
|
|
121
|
+
);
|
|
122
|
+
gateway.activate(makeCtx({ hasUI: false }));
|
|
123
|
+
expect(gateway.canConfirm()).toBe(true);
|
|
124
|
+
vi.unstubAllEnvs();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns false after deactivate", () => {
|
|
128
|
+
const gateway = new PromptingGateway(makeDeps());
|
|
129
|
+
gateway.activate(makeCtx({ hasUI: true }));
|
|
130
|
+
gateway.deactivate();
|
|
131
|
+
expect(gateway.canConfirm()).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("returns true after re-activate following deactivate", () => {
|
|
135
|
+
const gateway = new PromptingGateway(makeDeps());
|
|
136
|
+
gateway.activate(makeCtx({ hasUI: true }));
|
|
137
|
+
gateway.deactivate();
|
|
138
|
+
gateway.activate(makeCtx({ hasUI: true }));
|
|
139
|
+
expect(gateway.canConfirm()).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("prompt", () => {
|
|
144
|
+
it("rejects before activate", async () => {
|
|
145
|
+
const gateway = new PromptingGateway(makeDeps());
|
|
146
|
+
await expect(gateway.prompt(makeDetails())).rejects.toThrow(
|
|
147
|
+
"prompt called before the session was activated",
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("delegates to deps.prompter.prompt with the stored context", async () => {
|
|
152
|
+
const prompter = makePrompterApi();
|
|
153
|
+
const gateway = new PromptingGateway(makeDeps({ prompter }));
|
|
154
|
+
const ctx = makeCtx();
|
|
155
|
+
gateway.activate(ctx);
|
|
156
|
+
const details = makeDetails();
|
|
157
|
+
|
|
158
|
+
const result = await gateway.prompt(details);
|
|
159
|
+
|
|
160
|
+
expect(prompter.prompt).toHaveBeenCalledWith(ctx, details);
|
|
161
|
+
expect(result).toEqual({ approved: true, state: "approved" });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("uses the most recently activated context", async () => {
|
|
165
|
+
const prompter = makePrompterApi();
|
|
166
|
+
const gateway = new PromptingGateway(makeDeps({ prompter }));
|
|
167
|
+
const firstCtx = makeCtx({ cwd: "/first" });
|
|
168
|
+
const secondCtx = makeCtx({ cwd: "/second" });
|
|
169
|
+
|
|
170
|
+
gateway.activate(firstCtx);
|
|
171
|
+
gateway.activate(secondCtx);
|
|
172
|
+
|
|
173
|
+
await gateway.prompt(makeDetails());
|
|
174
|
+
|
|
175
|
+
expect(prompter.prompt).toHaveBeenCalledWith(
|
|
176
|
+
secondCtx,
|
|
177
|
+
expect.anything(),
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("rejects after deactivate", async () => {
|
|
182
|
+
const gateway = new PromptingGateway(makeDeps());
|
|
183
|
+
gateway.activate(makeCtx());
|
|
184
|
+
gateway.deactivate();
|
|
185
|
+
await expect(gateway.prompt(makeDetails())).rejects.toThrow(
|
|
186
|
+
"prompt called before the session was activated",
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("returns the prompter decision", async () => {
|
|
191
|
+
const decision: PermissionPromptDecision = {
|
|
192
|
+
approved: false,
|
|
193
|
+
state: "denied",
|
|
194
|
+
denialReason: "user declined",
|
|
195
|
+
};
|
|
196
|
+
const prompter = makePrompterApi();
|
|
197
|
+
prompter.prompt.mockResolvedValue(decision);
|
|
198
|
+
const gateway = new PromptingGateway(makeDeps({ prompter }));
|
|
199
|
+
gateway.activate(makeCtx());
|
|
200
|
+
|
|
201
|
+
const result = await gateway.prompt(makeDetails());
|
|
202
|
+
|
|
203
|
+
expect(result).toEqual(decision);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("lifecycle", () => {
|
|
208
|
+
afterEach(() => {
|
|
209
|
+
vi.unstubAllEnvs();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("activate then deactivate clears the stored context", () => {
|
|
213
|
+
const gateway = new PromptingGateway(makeDeps());
|
|
214
|
+
gateway.activate(makeCtx());
|
|
215
|
+
gateway.deactivate();
|
|
216
|
+
expect(gateway.canConfirm()).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("multiple activate calls update the stored context", () => {
|
|
220
|
+
const prompter = makePrompterApi();
|
|
221
|
+
const gateway = new PromptingGateway(makeDeps({ prompter }));
|
|
222
|
+
const ctx2 = makeCtx({ cwd: "/new" });
|
|
223
|
+
gateway.activate(makeCtx({ cwd: "/old" }));
|
|
224
|
+
gateway.activate(ctx2);
|
|
225
|
+
|
|
226
|
+
// canConfirm still works (context set)
|
|
227
|
+
expect(gateway.canConfirm()).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|