@gotgenes/pi-permission-system 10.3.0 → 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 +19 -0
- package/package.json +1 -1
- package/src/config-modal.ts +10 -8
- package/src/config-store.ts +13 -34
- 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 +68 -51
- package/src/permission-event-rpc.ts +19 -15
- package/src/permission-prompter.ts +4 -3
- package/src/permission-session.ts +10 -67
- package/src/permissions-service.ts +3 -5
- package/src/prompting-gateway.ts +104 -0
- package/src/session-logger.ts +63 -12
- package/test/composition-root.test.ts +85 -1
- package/test/config-modal.test.ts +13 -7
- package/test/config-store.test.ts +23 -49
- 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 +40 -112
- package/test/prompting-gateway.test.ts +230 -0
- package/test/session-logger.test.ts +151 -64
- package/src/runtime.ts +0 -147
- package/test/runtime.test.ts +0 -303
|
@@ -62,26 +62,12 @@ import {
|
|
|
62
62
|
ConfigStore,
|
|
63
63
|
type ConfigStoreDeps,
|
|
64
64
|
type ResolvedPolicyPathProvider,
|
|
65
|
-
type RuntimeContextRef,
|
|
66
65
|
} from "#src/config-store";
|
|
67
66
|
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
68
67
|
import type { ResolvedPolicyPaths } from "#src/policy-loader";
|
|
69
68
|
|
|
70
69
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
71
70
|
|
|
72
|
-
function makeContextRef(
|
|
73
|
-
initialCtx: ExtensionContext | null = null,
|
|
74
|
-
): RuntimeContextRef & { _ctx: ExtensionContext | null } {
|
|
75
|
-
const ref = {
|
|
76
|
-
_ctx: initialCtx,
|
|
77
|
-
get: () => ref._ctx,
|
|
78
|
-
set: (ctx: ExtensionContext) => {
|
|
79
|
-
ref._ctx = ctx;
|
|
80
|
-
},
|
|
81
|
-
};
|
|
82
|
-
return ref;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
71
|
function makePolicyPathProvider(
|
|
86
72
|
paths?: Partial<ResolvedPolicyPaths>,
|
|
87
73
|
): ResolvedPolicyPathProvider {
|
|
@@ -104,10 +90,8 @@ function makePolicyPathProvider(
|
|
|
104
90
|
|
|
105
91
|
function makeLogger() {
|
|
106
92
|
return {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
writeReviewLog:
|
|
110
|
-
vi.fn<(event: string, details?: Record<string, unknown>) => void>(),
|
|
93
|
+
debug: vi.fn<(event: string, details?: Record<string, unknown>) => void>(),
|
|
94
|
+
review: vi.fn<(event: string, details?: Record<string, unknown>) => void>(),
|
|
111
95
|
};
|
|
112
96
|
}
|
|
113
97
|
|
|
@@ -133,19 +117,16 @@ function makeCommandCtx(
|
|
|
133
117
|
|
|
134
118
|
function makeStore(overrides: Partial<ConfigStoreDeps> = {}): {
|
|
135
119
|
store: ConfigStore;
|
|
136
|
-
contextRef: RuntimeContextRef & { _ctx: ExtensionContext | null };
|
|
137
120
|
logger: ReturnType<typeof makeLogger>;
|
|
138
121
|
} {
|
|
139
|
-
const contextRef = makeContextRef();
|
|
140
122
|
const logger = makeLogger();
|
|
141
123
|
const deps: ConfigStoreDeps = {
|
|
142
124
|
agentDir: "/test/agent",
|
|
143
|
-
context: contextRef,
|
|
144
125
|
policyPaths: makePolicyPathProvider(),
|
|
145
126
|
logger,
|
|
146
127
|
...overrides,
|
|
147
128
|
};
|
|
148
|
-
return { store: new ConfigStore(deps),
|
|
129
|
+
return { store: new ConfigStore(deps), logger };
|
|
149
130
|
}
|
|
150
131
|
|
|
151
132
|
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
@@ -180,10 +161,9 @@ describe("ConfigStore", () => {
|
|
|
180
161
|
// ── refresh() ─────────────────────────────────────────────────────────
|
|
181
162
|
|
|
182
163
|
describe("refresh()", () => {
|
|
183
|
-
it("
|
|
184
|
-
const { store
|
|
185
|
-
|
|
186
|
-
store.refresh();
|
|
164
|
+
it("uses the passed ctx cwd for loadAndMergeConfigs", () => {
|
|
165
|
+
const { store } = makeStore();
|
|
166
|
+
store.refresh(makeCtx({ cwd: "/my/project" }));
|
|
187
167
|
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
|
|
188
168
|
"/test/agent",
|
|
189
169
|
"/my/project",
|
|
@@ -191,19 +171,14 @@ describe("ConfigStore", () => {
|
|
|
191
171
|
);
|
|
192
172
|
});
|
|
193
173
|
|
|
194
|
-
it("
|
|
195
|
-
const { store
|
|
196
|
-
const ctx = makeCtx({ cwd: "/new/project" });
|
|
197
|
-
store.refresh(ctx);
|
|
198
|
-
expect(contextRef._ctx).toBe(ctx);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it("does not overwrite context when ctx is omitted", () => {
|
|
202
|
-
const { store, contextRef } = makeStore();
|
|
203
|
-
const existing = makeCtx();
|
|
204
|
-
contextRef._ctx = existing;
|
|
174
|
+
it("uses empty string cwd when no ctx is provided", () => {
|
|
175
|
+
const { store } = makeStore();
|
|
205
176
|
store.refresh();
|
|
206
|
-
expect(
|
|
177
|
+
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
|
|
178
|
+
"/test/agent",
|
|
179
|
+
"",
|
|
180
|
+
expect.any(String),
|
|
181
|
+
);
|
|
207
182
|
});
|
|
208
183
|
|
|
209
184
|
it("updates current() with normalized merged result", () => {
|
|
@@ -220,7 +195,7 @@ describe("ConfigStore", () => {
|
|
|
220
195
|
it("writes config.loaded debug log", () => {
|
|
221
196
|
const { store, logger } = makeStore();
|
|
222
197
|
store.refresh();
|
|
223
|
-
expect(logger.
|
|
198
|
+
expect(logger.debug).toHaveBeenCalledWith(
|
|
224
199
|
"config.loaded",
|
|
225
200
|
expect.objectContaining({ debugLog: false }),
|
|
226
201
|
);
|
|
@@ -359,7 +334,7 @@ describe("ConfigStore", () => {
|
|
|
359
334
|
it("writes config.saved debug log after a successful save", () => {
|
|
360
335
|
const { store, logger } = makeStore();
|
|
361
336
|
store.save({ ...DEFAULT_EXTENSION_CONFIG }, makeCommandCtx());
|
|
362
|
-
expect(logger.
|
|
337
|
+
expect(logger.debug).toHaveBeenCalledWith(
|
|
363
338
|
"config.saved",
|
|
364
339
|
expect.objectContaining({ debugLog: false }),
|
|
365
340
|
);
|
|
@@ -380,7 +355,7 @@ describe("ConfigStore", () => {
|
|
|
380
355
|
// current() is not updated on failure
|
|
381
356
|
expect(store.current()).toEqual(DEFAULT_EXTENSION_CONFIG);
|
|
382
357
|
// no debug log on failure
|
|
383
|
-
expect(logger.
|
|
358
|
+
expect(logger.debug).not.toHaveBeenCalledWith(
|
|
384
359
|
"config.saved",
|
|
385
360
|
expect.anything(),
|
|
386
361
|
);
|
|
@@ -404,11 +379,11 @@ describe("ConfigStore", () => {
|
|
|
404
379
|
it("writes config.resolved to both review and debug logs", () => {
|
|
405
380
|
const { store, logger } = makeStore();
|
|
406
381
|
store.logResolvedPaths();
|
|
407
|
-
expect(logger.
|
|
382
|
+
expect(logger.review).toHaveBeenCalledWith(
|
|
408
383
|
"config.resolved",
|
|
409
384
|
expect.any(Object),
|
|
410
385
|
);
|
|
411
|
-
expect(logger.
|
|
386
|
+
expect(logger.debug).toHaveBeenCalledWith(
|
|
412
387
|
"config.resolved",
|
|
413
388
|
expect.any(Object),
|
|
414
389
|
);
|
|
@@ -422,13 +397,12 @@ describe("ConfigStore", () => {
|
|
|
422
397
|
});
|
|
423
398
|
|
|
424
399
|
it("passes legacy detection results to buildResolvedConfigLogEntry", () => {
|
|
425
|
-
const { store
|
|
426
|
-
contextRef._ctx = makeCtx({ cwd: "/some/project" });
|
|
400
|
+
const { store } = makeStore();
|
|
427
401
|
// Make one legacy path exist
|
|
428
402
|
mockExistsSync.mockImplementation((p: string) =>
|
|
429
403
|
p.includes("policies.json"),
|
|
430
404
|
);
|
|
431
|
-
store.logResolvedPaths();
|
|
405
|
+
store.logResolvedPaths("/some/project");
|
|
432
406
|
expect(mockBuildResolvedConfigLogEntry).toHaveBeenCalledWith(
|
|
433
407
|
expect.objectContaining({
|
|
434
408
|
legacyGlobalPolicyDetected: expect.any(Boolean),
|
|
@@ -438,9 +412,9 @@ describe("ConfigStore", () => {
|
|
|
438
412
|
);
|
|
439
413
|
});
|
|
440
414
|
|
|
441
|
-
it("does not check project legacy path when
|
|
442
|
-
const { store } = makeStore();
|
|
443
|
-
store.logResolvedPaths();
|
|
415
|
+
it("does not check project legacy path when no cwd is provided", () => {
|
|
416
|
+
const { store } = makeStore();
|
|
417
|
+
store.logResolvedPaths(); // no cwd
|
|
444
418
|
// existsSync called for global and ext-config legacy paths only (not project)
|
|
445
419
|
const calls = mockExistsSync.mock.calls.map(([p]: [string]) => p);
|
|
446
420
|
const projectCalls = calls.filter(
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
import type { ForwardedPermissionLogger } from "#src/forwarded-permissions/io";
|
|
4
3
|
import {
|
|
5
4
|
formatUnknownErrorMessage,
|
|
6
5
|
isErrnoCode,
|
|
7
6
|
logPermissionForwardingError,
|
|
8
7
|
logPermissionForwardingWarning,
|
|
9
8
|
} from "#src/forwarded-permissions/io";
|
|
9
|
+
import type { DebugReviewLogger } from "#src/session-logger";
|
|
10
10
|
|
|
11
11
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
12
12
|
|
|
13
|
-
function makeLogger():
|
|
13
|
+
function makeLogger(): DebugReviewLogger {
|
|
14
14
|
return {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
review: vi.fn(),
|
|
16
|
+
debug: vi.fn(),
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -59,28 +59,27 @@ describe("isErrnoCode", () => {
|
|
|
59
59
|
// ── logPermissionForwardingWarning ─────────────────────────────────────────
|
|
60
60
|
|
|
61
61
|
describe("logPermissionForwardingWarning", () => {
|
|
62
|
-
it("calls logger.
|
|
62
|
+
it("calls logger.review with the warning event", () => {
|
|
63
63
|
const logger = makeLogger();
|
|
64
64
|
logPermissionForwardingWarning(logger, "something went wrong");
|
|
65
|
-
expect(logger.
|
|
65
|
+
expect(logger.review).toHaveBeenCalledWith(
|
|
66
66
|
"permission_forwarding.warning",
|
|
67
67
|
{ message: "something went wrong" },
|
|
68
68
|
);
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
it("calls logger.
|
|
71
|
+
it("calls logger.debug with the warning event", () => {
|
|
72
72
|
const logger = makeLogger();
|
|
73
73
|
logPermissionForwardingWarning(logger, "something went wrong");
|
|
74
|
-
expect(logger.
|
|
75
|
-
"
|
|
76
|
-
|
|
77
|
-
);
|
|
74
|
+
expect(logger.debug).toHaveBeenCalledWith("permission_forwarding.warning", {
|
|
75
|
+
message: "something went wrong",
|
|
76
|
+
});
|
|
78
77
|
});
|
|
79
78
|
|
|
80
79
|
it("includes formatted error when an error is provided", () => {
|
|
81
80
|
const logger = makeLogger();
|
|
82
81
|
logPermissionForwardingWarning(logger, "bad thing", new Error("fs fail"));
|
|
83
|
-
expect(logger.
|
|
82
|
+
expect(logger.review).toHaveBeenCalledWith(
|
|
84
83
|
"permission_forwarding.warning",
|
|
85
84
|
{ message: "bad thing", error: "fs fail" },
|
|
86
85
|
);
|
|
@@ -102,31 +101,29 @@ describe("logPermissionForwardingWarning", () => {
|
|
|
102
101
|
// ── logPermissionForwardingError ───────────────────────────────────────────
|
|
103
102
|
|
|
104
103
|
describe("logPermissionForwardingError", () => {
|
|
105
|
-
it("calls logger.
|
|
104
|
+
it("calls logger.review with the error event", () => {
|
|
106
105
|
const logger = makeLogger();
|
|
107
106
|
logPermissionForwardingError(logger, "critical failure");
|
|
108
|
-
expect(logger.
|
|
109
|
-
"
|
|
110
|
-
|
|
111
|
-
);
|
|
107
|
+
expect(logger.review).toHaveBeenCalledWith("permission_forwarding.error", {
|
|
108
|
+
message: "critical failure",
|
|
109
|
+
});
|
|
112
110
|
});
|
|
113
111
|
|
|
114
|
-
it("calls logger.
|
|
112
|
+
it("calls logger.debug with the error event", () => {
|
|
115
113
|
const logger = makeLogger();
|
|
116
114
|
logPermissionForwardingError(logger, "critical failure");
|
|
117
|
-
expect(logger.
|
|
118
|
-
"
|
|
119
|
-
|
|
120
|
-
);
|
|
115
|
+
expect(logger.debug).toHaveBeenCalledWith("permission_forwarding.error", {
|
|
116
|
+
message: "critical failure",
|
|
117
|
+
});
|
|
121
118
|
});
|
|
122
119
|
|
|
123
120
|
it("includes formatted error when an error is provided", () => {
|
|
124
121
|
const logger = makeLogger();
|
|
125
122
|
logPermissionForwardingError(logger, "io error", new Error("ENOENT"));
|
|
126
|
-
expect(logger.
|
|
127
|
-
"
|
|
128
|
-
|
|
129
|
-
);
|
|
123
|
+
expect(logger.review).toHaveBeenCalledWith("permission_forwarding.error", {
|
|
124
|
+
message: "io error",
|
|
125
|
+
error: "ENOENT",
|
|
126
|
+
});
|
|
130
127
|
});
|
|
131
128
|
|
|
132
129
|
it("does not throw when logger is null", () => {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { describe, expect, it, vi } from "vitest";
|
|
13
13
|
|
|
14
14
|
import { EXTENSION_TAG } from "#src/denial-messages";
|
|
15
|
+
import type { GatePrompter } from "#src/gate-prompter";
|
|
15
16
|
import { formatExternalDirectoryAskPrompt } from "#src/handlers/gates/external-directory-messages";
|
|
16
17
|
import type { PermissionCheckResult } from "#src/types";
|
|
17
18
|
|
|
@@ -218,13 +219,13 @@ describe("external_directory — allow external reads, gate external writes (#14
|
|
|
218
219
|
});
|
|
219
220
|
|
|
220
221
|
it("prompts for write to external path when external_directory allows but write is ask", async () => {
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
222
|
+
const { handler, prompter } = makeHandler({
|
|
223
|
+
session: { checkPermission: makeExtDirCheck("allow", "ask") },
|
|
224
|
+
prompter: {
|
|
225
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
226
|
+
prompt: vi
|
|
227
|
+
.fn<GatePrompter["prompt"]>()
|
|
228
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
228
229
|
},
|
|
229
230
|
tools: ALL_TOOLS,
|
|
230
231
|
});
|
|
@@ -234,7 +235,7 @@ describe("external_directory — allow external reads, gate external writes (#14
|
|
|
234
235
|
const result = await handler.handleToolCall(event, makeCtx());
|
|
235
236
|
// external_directory passes; write gate prompts and user approves
|
|
236
237
|
expect(result).toEqual({});
|
|
237
|
-
expect(prompt).toHaveBeenCalledOnce();
|
|
238
|
+
expect(prompter.prompt).toHaveBeenCalledOnce();
|
|
238
239
|
});
|
|
239
240
|
|
|
240
241
|
it("blocks write to external path when external_directory allows but write is deny", async () => {
|
|
@@ -341,10 +342,11 @@ describe("external_directory policy state — deny", () => {
|
|
|
341
342
|
describe("external_directory policy state — ask", () => {
|
|
342
343
|
it("does not block when user approves", async () => {
|
|
343
344
|
const { handler } = makeHandler({
|
|
344
|
-
session: {
|
|
345
|
-
|
|
345
|
+
session: { checkPermission: makeExtDirCheck("ask") },
|
|
346
|
+
prompter: {
|
|
347
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
346
348
|
prompt: vi
|
|
347
|
-
.fn()
|
|
349
|
+
.fn<GatePrompter["prompt"]>()
|
|
348
350
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
349
351
|
},
|
|
350
352
|
tools: ALL_TOOLS,
|
|
@@ -356,10 +358,11 @@ describe("external_directory policy state — ask", () => {
|
|
|
356
358
|
|
|
357
359
|
it("emits user_approved decision when user approves", async () => {
|
|
358
360
|
const { handler, events } = makeHandler({
|
|
359
|
-
session: {
|
|
360
|
-
|
|
361
|
+
session: { checkPermission: makeExtDirCheck("ask") },
|
|
362
|
+
prompter: {
|
|
363
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
361
364
|
prompt: vi
|
|
362
|
-
.fn()
|
|
365
|
+
.fn<GatePrompter["prompt"]>()
|
|
363
366
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
364
367
|
},
|
|
365
368
|
tools: ALL_TOOLS,
|
|
@@ -379,9 +382,12 @@ describe("external_directory policy state — ask", () => {
|
|
|
379
382
|
|
|
380
383
|
it("blocks when user denies", async () => {
|
|
381
384
|
const { handler } = makeHandler({
|
|
382
|
-
session: {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
+
session: { checkPermission: makeExtDirCheck("ask") },
|
|
386
|
+
prompter: {
|
|
387
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
388
|
+
prompt: vi
|
|
389
|
+
.fn<GatePrompter["prompt"]>()
|
|
390
|
+
.mockResolvedValue({ approved: false, state: "denied" }),
|
|
385
391
|
},
|
|
386
392
|
tools: ALL_TOOLS,
|
|
387
393
|
});
|
|
@@ -392,9 +398,12 @@ describe("external_directory policy state — ask", () => {
|
|
|
392
398
|
|
|
393
399
|
it("emits user_denied decision when user denies", async () => {
|
|
394
400
|
const { handler, events } = makeHandler({
|
|
395
|
-
session: {
|
|
396
|
-
|
|
397
|
-
|
|
401
|
+
session: { checkPermission: makeExtDirCheck("ask") },
|
|
402
|
+
prompter: {
|
|
403
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
404
|
+
prompt: vi
|
|
405
|
+
.fn<GatePrompter["prompt"]>()
|
|
406
|
+
.mockResolvedValue({ approved: false, state: "denied" }),
|
|
398
407
|
},
|
|
399
408
|
tools: ALL_TOOLS,
|
|
400
409
|
});
|
|
@@ -413,9 +422,10 @@ describe("external_directory policy state — ask", () => {
|
|
|
413
422
|
|
|
414
423
|
it("block reason includes denialReason when user provides one", async () => {
|
|
415
424
|
const { handler } = makeHandler({
|
|
416
|
-
session: {
|
|
417
|
-
|
|
418
|
-
|
|
425
|
+
session: { checkPermission: makeExtDirCheck("ask") },
|
|
426
|
+
prompter: {
|
|
427
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
428
|
+
prompt: vi.fn<GatePrompter["prompt"]>().mockResolvedValue({
|
|
419
429
|
approved: false,
|
|
420
430
|
state: "denied",
|
|
421
431
|
denialReason: "not needed",
|
|
@@ -431,9 +441,10 @@ describe("external_directory policy state — ask", () => {
|
|
|
431
441
|
|
|
432
442
|
it("blocks with confirmation_unavailable when no UI is available", async () => {
|
|
433
443
|
const { handler } = makeHandler({
|
|
434
|
-
session: {
|
|
435
|
-
|
|
436
|
-
|
|
444
|
+
session: { checkPermission: makeExtDirCheck("ask") },
|
|
445
|
+
prompter: {
|
|
446
|
+
canConfirm: vi.fn().mockReturnValue(false),
|
|
447
|
+
prompt: vi.fn<GatePrompter["prompt"]>(),
|
|
437
448
|
},
|
|
438
449
|
tools: ALL_TOOLS,
|
|
439
450
|
});
|
|
@@ -448,9 +459,10 @@ describe("external_directory policy state — ask", () => {
|
|
|
448
459
|
|
|
449
460
|
it("writes review-log entry with confirmation_unavailable when no UI", async () => {
|
|
450
461
|
const { handler, session } = makeHandler({
|
|
451
|
-
session: {
|
|
452
|
-
|
|
453
|
-
|
|
462
|
+
session: { checkPermission: makeExtDirCheck("ask") },
|
|
463
|
+
prompter: {
|
|
464
|
+
canConfirm: vi.fn().mockReturnValue(false),
|
|
465
|
+
prompt: vi.fn<GatePrompter["prompt"]>(),
|
|
454
466
|
},
|
|
455
467
|
tools: ALL_TOOLS,
|
|
456
468
|
});
|
|
@@ -469,9 +481,10 @@ describe("external_directory policy state — ask", () => {
|
|
|
469
481
|
|
|
470
482
|
it("emits confirmation_unavailable decision when no UI", async () => {
|
|
471
483
|
const { handler, events } = makeHandler({
|
|
472
|
-
session: {
|
|
473
|
-
|
|
474
|
-
|
|
484
|
+
session: { checkPermission: makeExtDirCheck("ask") },
|
|
485
|
+
prompter: {
|
|
486
|
+
canConfirm: vi.fn().mockReturnValue(false),
|
|
487
|
+
prompt: vi.fn<GatePrompter["prompt"]>(),
|
|
475
488
|
},
|
|
476
489
|
tools: ALL_TOOLS,
|
|
477
490
|
});
|
|
@@ -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
|
});
|