@gotgenes/pi-permission-system 10.5.1 → 10.5.3
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 +21 -0
- package/package.json +1 -1
- package/src/common.ts +7 -0
- package/src/config-loader.ts +31 -2
- package/src/extension-config.ts +1 -8
- package/src/input-normalizer.ts +20 -8
- package/src/path-utils.ts +1 -10
- package/test/before-agent-start-cache.test.ts +89 -0
- package/test/common.test.ts +32 -1
- package/test/config-loader.test.ts +108 -0
- package/test/config-store.test.ts +14 -0
- package/test/extension-config.test.ts +0 -31
- package/test/handlers/external-directory-session-dedup.test.ts +96 -0
- package/test/handlers/gates/bash-path.test.ts +57 -0
- package/test/handlers/gates/path.test.ts +58 -0
- package/test/handlers/tool-call.test.ts +103 -0
- package/test/helpers/manager-harness.ts +61 -0
- package/test/input-normalizer.test.ts +77 -1
- package/test/logging.test.ts +51 -0
- package/test/path-utils.test.ts +10 -0
- package/test/permission-forwarding.test.ts +73 -0
- package/test/permission-manager-unified.test.ts +1577 -3
- package/test/skill-prompt-sanitizer.test.ts +130 -0
- package/test/status.test.ts +10 -0
- package/test/system-prompt-sanitizer.test.ts +68 -0
- package/test/tool-registry.test.ts +42 -0
- package/test/yolo-mode.test.ts +78 -0
- package/test/permission-system.test.ts +0 -2785
|
@@ -216,3 +216,60 @@ describe("describeBashPathGate", () => {
|
|
|
216
216
|
expect(desc.decision.value).toBe(".env");
|
|
217
217
|
});
|
|
218
218
|
});
|
|
219
|
+
|
|
220
|
+
// Home-relative path characterization (#350) ──────────────────────────────
|
|
221
|
+
//
|
|
222
|
+
// The parser extracts ~/... tokens from bash commands; the resolver receives
|
|
223
|
+
// the raw token and normalizeInput handles expansion. These tests verify the
|
|
224
|
+
// gate correctly dispatches ~/... tokens through the deny/ask path.
|
|
225
|
+
|
|
226
|
+
describe("describeBashPathGate — home-relative paths", () => {
|
|
227
|
+
it("extracts ~/... token and builds descriptor on deny", async () => {
|
|
228
|
+
// node:os is mocked: homedir() returns "/mock/home".
|
|
229
|
+
// cat ~/.ssh/config → token "~/.ssh/config" extracted.
|
|
230
|
+
const resolver = makePathDispatchResolver(
|
|
231
|
+
{
|
|
232
|
+
"~/.ssh/config": makeCheckResult({
|
|
233
|
+
state: "deny",
|
|
234
|
+
matchedPattern: "~/.ssh/*",
|
|
235
|
+
}),
|
|
236
|
+
},
|
|
237
|
+
makeCheckResult({ state: "allow" }),
|
|
238
|
+
);
|
|
239
|
+
const result = (await describeGate(
|
|
240
|
+
makeTcc({ input: { command: "cat ~/.ssh/config" } }),
|
|
241
|
+
resolver,
|
|
242
|
+
)) as GateDescriptor;
|
|
243
|
+
|
|
244
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
245
|
+
expect(result.preCheck?.state).toBe("deny");
|
|
246
|
+
expect(result.denialContext).toMatchObject({
|
|
247
|
+
kind: "bash_path",
|
|
248
|
+
command: "cat ~/.ssh/config",
|
|
249
|
+
pathValue: "~/.ssh/config",
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("extracts $HOME/... token and builds descriptor on deny", async () => {
|
|
254
|
+
const resolver = makePathDispatchResolver(
|
|
255
|
+
{
|
|
256
|
+
"$HOME/.ssh/config": makeCheckResult({
|
|
257
|
+
state: "deny",
|
|
258
|
+
matchedPattern: "$HOME/.ssh/*",
|
|
259
|
+
}),
|
|
260
|
+
},
|
|
261
|
+
makeCheckResult({ state: "allow" }),
|
|
262
|
+
);
|
|
263
|
+
const result = (await describeGate(
|
|
264
|
+
makeTcc({ input: { command: "cat $HOME/.ssh/config" } }),
|
|
265
|
+
resolver,
|
|
266
|
+
)) as GateDescriptor;
|
|
267
|
+
|
|
268
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
269
|
+
expect(result.preCheck?.state).toBe("deny");
|
|
270
|
+
expect(result.denialContext).toMatchObject({
|
|
271
|
+
kind: "bash_path",
|
|
272
|
+
pathValue: "$HOME/.ssh/config",
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -148,3 +148,61 @@ describe("describePathGate", () => {
|
|
|
148
148
|
);
|
|
149
149
|
});
|
|
150
150
|
});
|
|
151
|
+
|
|
152
|
+
// Home-relative path characterization (#350) ──────────────────────────────
|
|
153
|
+
//
|
|
154
|
+
// The gate passes the raw path to the resolver; home expansion is handled
|
|
155
|
+
// downstream by normalizeInput. These tests lock in that the gate works
|
|
156
|
+
// correctly when the tool input contains a ~/... or $HOME/... path.
|
|
157
|
+
|
|
158
|
+
describe("describePathGate — home-relative paths", () => {
|
|
159
|
+
it("passes raw ~/... path to resolver and builds descriptor on deny", () => {
|
|
160
|
+
const resolver = makeResolver(
|
|
161
|
+
makeCheckResult({ state: "deny", matchedPattern: "~/.ssh/*" }),
|
|
162
|
+
);
|
|
163
|
+
const result = describePathGate(
|
|
164
|
+
makeTcc({ input: { path: "~/.ssh/config" } }),
|
|
165
|
+
resolver,
|
|
166
|
+
) as GateDescriptor;
|
|
167
|
+
|
|
168
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
169
|
+
expect(result.preCheck?.state).toBe("deny");
|
|
170
|
+
// Raw path preserved in denial context for display.
|
|
171
|
+
expect(result.denialContext).toMatchObject({
|
|
172
|
+
kind: "path",
|
|
173
|
+
toolName: "read",
|
|
174
|
+
pathValue: "~/.ssh/config",
|
|
175
|
+
});
|
|
176
|
+
expect(resolver.resolve).toHaveBeenCalledWith(
|
|
177
|
+
"path",
|
|
178
|
+
{ path: "~/.ssh/config" },
|
|
179
|
+
undefined,
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("passes raw $HOME/... path to resolver and builds descriptor on deny", () => {
|
|
184
|
+
const resolver = makeResolver(
|
|
185
|
+
makeCheckResult({ state: "deny", matchedPattern: "$HOME/.ssh/*" }),
|
|
186
|
+
);
|
|
187
|
+
const result = describePathGate(
|
|
188
|
+
makeTcc({ input: { path: "$HOME/.ssh/config" } }),
|
|
189
|
+
resolver,
|
|
190
|
+
) as GateDescriptor;
|
|
191
|
+
|
|
192
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
193
|
+
expect(result.preCheck?.state).toBe("deny");
|
|
194
|
+
expect(result.denialContext).toMatchObject({
|
|
195
|
+
kind: "path",
|
|
196
|
+
pathValue: "$HOME/.ssh/config",
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("returns null when home-relative path resolves to allow", () => {
|
|
201
|
+
const resolver = makeResolver(makeCheckResult({ state: "allow" }));
|
|
202
|
+
const result = describePathGate(
|
|
203
|
+
makeTcc({ input: { path: "~/.ssh/config" } }),
|
|
204
|
+
resolver,
|
|
205
|
+
);
|
|
206
|
+
expect(result).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -290,3 +290,106 @@ describe("handleToolCall — bash command chain gate", () => {
|
|
|
290
290
|
expect(result).toEqual({});
|
|
291
291
|
});
|
|
292
292
|
});
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// Moved from permission-system.test.ts catch-all (#342)
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
describe("handleToolCall — bash external-directory policy states", () => {
|
|
299
|
+
it("allows bash command with only internal paths when external_directory is denied", async () => {
|
|
300
|
+
const { handler } = makeHandler({ tools: ["bash"] });
|
|
301
|
+
const event = makeToolCallEvent("bash", {
|
|
302
|
+
input: { command: "cat src/index.ts" },
|
|
303
|
+
});
|
|
304
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
305
|
+
expect(result).toEqual({});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("blocks bash command with external path when external_directory is ask and no UI", async () => {
|
|
309
|
+
const { handler } = makeHandler({
|
|
310
|
+
session: {
|
|
311
|
+
checkPermission: makeSurfaceCheck({
|
|
312
|
+
external_directory: { state: "ask", source: "special" },
|
|
313
|
+
}),
|
|
314
|
+
},
|
|
315
|
+
tools: ["bash"],
|
|
316
|
+
prompter: {
|
|
317
|
+
canConfirm: vi.fn().mockReturnValue(false),
|
|
318
|
+
prompt: vi.fn().mockResolvedValue({ approved: false, state: "denied" }),
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
const event = makeToolCallEvent("bash", {
|
|
322
|
+
input: { command: "cat /etc/hosts" },
|
|
323
|
+
});
|
|
324
|
+
const result = await handler.handleToolCall(
|
|
325
|
+
event,
|
|
326
|
+
makeCtx({ hasUI: false }),
|
|
327
|
+
);
|
|
328
|
+
expect(result).toMatchObject({ block: true });
|
|
329
|
+
expect(String((result as { reason?: unknown }).reason)).toMatch(
|
|
330
|
+
/no interactive UI/i,
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("allows bash command with external path when external_directory is allow", async () => {
|
|
335
|
+
const { handler } = makeHandler({
|
|
336
|
+
session: {
|
|
337
|
+
checkPermission: makeSurfaceCheck({
|
|
338
|
+
external_directory: { state: "allow", source: "special" },
|
|
339
|
+
}),
|
|
340
|
+
},
|
|
341
|
+
tools: ["bash"],
|
|
342
|
+
});
|
|
343
|
+
const event = makeToolCallEvent("bash", {
|
|
344
|
+
input: { command: "cat /etc/hosts" },
|
|
345
|
+
});
|
|
346
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
347
|
+
expect(result).toEqual({});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("applies bash pattern deny after external_directory allow", async () => {
|
|
351
|
+
const { handler } = makeHandler({
|
|
352
|
+
session: {
|
|
353
|
+
checkPermission: makeSurfaceCheck(
|
|
354
|
+
{
|
|
355
|
+
external_directory: { state: "allow", source: "special" },
|
|
356
|
+
bash: { state: "deny", source: "bash" },
|
|
357
|
+
},
|
|
358
|
+
{ state: "allow" },
|
|
359
|
+
),
|
|
360
|
+
},
|
|
361
|
+
tools: ["bash"],
|
|
362
|
+
});
|
|
363
|
+
const event = makeToolCallEvent("bash", {
|
|
364
|
+
input: { command: "cat /etc/hosts" },
|
|
365
|
+
});
|
|
366
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
367
|
+
expect(result).toMatchObject({ block: true });
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe("handleToolCall — generic ask prompt content", () => {
|
|
372
|
+
it("ask prompt includes serialized tool input for informed approval", async () => {
|
|
373
|
+
const { handler, prompter } = makeHandler({
|
|
374
|
+
session: {
|
|
375
|
+
checkPermission: makeSurfaceCheck({
|
|
376
|
+
weather_lookup: { state: "ask" },
|
|
377
|
+
}),
|
|
378
|
+
},
|
|
379
|
+
tools: ["weather_lookup"],
|
|
380
|
+
prompter: {
|
|
381
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
382
|
+
prompt: vi.fn().mockResolvedValue({ approved: false, state: "denied" }),
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
const event = makeToolCallEvent("weather_lookup", {
|
|
386
|
+
input: { city: "Chicago", units: "metric" },
|
|
387
|
+
});
|
|
388
|
+
await handler.handleToolCall(event, makeCtx());
|
|
389
|
+
expect(vi.mocked(prompter.prompt)).toHaveBeenCalledTimes(1);
|
|
390
|
+
const promptDetails = vi.mocked(prompter.prompt).mock.calls[0][0];
|
|
391
|
+
expect(promptDetails.message).toMatch(
|
|
392
|
+
/\{"city":"Chicago","units":"metric"\}/,
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
@@ -15,6 +15,11 @@ export type CreateManagerOptions = {
|
|
|
15
15
|
mcpServerNames?: readonly string[];
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
export type CreateManagerWithProjectOptions = CreateManagerOptions & {
|
|
19
|
+
projectConfig?: ScopeConfig;
|
|
20
|
+
projectAgentFiles?: Record<string, string>;
|
|
21
|
+
};
|
|
22
|
+
|
|
18
23
|
export function createManager(
|
|
19
24
|
config: ScopeConfig,
|
|
20
25
|
agentFiles: Record<string, string> = {},
|
|
@@ -49,3 +54,59 @@ export function createManager(
|
|
|
49
54
|
},
|
|
50
55
|
};
|
|
51
56
|
}
|
|
57
|
+
|
|
58
|
+
export function createManagerWithProject(
|
|
59
|
+
config: ScopeConfig,
|
|
60
|
+
agentFiles: Record<string, string> = {},
|
|
61
|
+
options: CreateManagerWithProjectOptions = {},
|
|
62
|
+
) {
|
|
63
|
+
const baseDir = mkdtempSync(
|
|
64
|
+
join(tmpdir(), "pi-permission-system-proj-test-"),
|
|
65
|
+
);
|
|
66
|
+
const globalConfigPath = join(baseDir, "pi-permissions.jsonc");
|
|
67
|
+
const agentsDir = join(baseDir, "agents");
|
|
68
|
+
const projectRoot = join(baseDir, "project");
|
|
69
|
+
const projectGlobalConfigPath = join(projectRoot, "pi-permissions.jsonc");
|
|
70
|
+
const projectAgentsDir = join(projectRoot, "agents");
|
|
71
|
+
|
|
72
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
73
|
+
mkdirSync(projectAgentsDir, { recursive: true });
|
|
74
|
+
|
|
75
|
+
writeFileSync(
|
|
76
|
+
globalConfigPath,
|
|
77
|
+
`${JSON.stringify(config, null, 2)}\n`,
|
|
78
|
+
"utf8",
|
|
79
|
+
);
|
|
80
|
+
if (options.projectConfig) {
|
|
81
|
+
writeFileSync(
|
|
82
|
+
projectGlobalConfigPath,
|
|
83
|
+
`${JSON.stringify(options.projectConfig, null, 2)}\n`,
|
|
84
|
+
"utf8",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const [name, content] of Object.entries(agentFiles)) {
|
|
89
|
+
writeFileSync(join(agentsDir, `${name}.md`), content, "utf8");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const [name, content] of Object.entries(
|
|
93
|
+
options.projectAgentFiles ?? {},
|
|
94
|
+
)) {
|
|
95
|
+
writeFileSync(join(projectAgentsDir, `${name}.md`), content, "utf8");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const manager = new PermissionManager({
|
|
99
|
+
globalConfigPath,
|
|
100
|
+
agentsDir,
|
|
101
|
+
projectGlobalConfigPath,
|
|
102
|
+
projectAgentsDir,
|
|
103
|
+
mcpServerNames: options.mcpServerNames,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
manager,
|
|
108
|
+
cleanup: (): void => {
|
|
109
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -1,7 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const mockHomedir = vi.hoisted(() => vi.fn(() => "/mock/home"));
|
|
5
|
+
|
|
6
|
+
vi.mock("node:os", () => ({
|
|
7
|
+
homedir: mockHomedir,
|
|
8
|
+
default: { homedir: mockHomedir },
|
|
9
|
+
}));
|
|
10
|
+
|
|
2
11
|
import { normalizeInput } from "#src/input-normalizer";
|
|
3
12
|
import { createMcpPermissionTargets } from "#src/mcp-targets";
|
|
4
13
|
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
mockHomedir.mockClear();
|
|
16
|
+
});
|
|
17
|
+
|
|
5
18
|
describe("normalizeInput — non-MCP surfaces", () => {
|
|
6
19
|
describe("special / path", () => {
|
|
7
20
|
it("uses path from input as the lookup value", () => {
|
|
@@ -21,10 +34,40 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
21
34
|
expect(result.values).toEqual(["*"]);
|
|
22
35
|
});
|
|
23
36
|
|
|
37
|
+
it("falls back to '*' when path is an empty string", () => {
|
|
38
|
+
const result = normalizeInput("path", { path: "" }, []);
|
|
39
|
+
expect(result.values).toEqual(["*"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("falls back to '*' when path is whitespace-only", () => {
|
|
43
|
+
const result = normalizeInput("path", { path: " " }, []);
|
|
44
|
+
expect(result.values).toEqual(["*"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
24
47
|
it("handles null input", () => {
|
|
25
48
|
const result = normalizeInput("path", null, []);
|
|
26
49
|
expect(result.values).toEqual(["*"]);
|
|
27
50
|
});
|
|
51
|
+
|
|
52
|
+
it("expands ~/... path value to absolute home path", () => {
|
|
53
|
+
const result = normalizeInput("path", { path: "~/.ssh/config" }, []);
|
|
54
|
+
expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("expands $HOME/... path value to absolute home path", () => {
|
|
58
|
+
const result = normalizeInput("path", { path: "$HOME/.ssh/config" }, []);
|
|
59
|
+
expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("does not expand non-home values", () => {
|
|
63
|
+
const result = normalizeInput("path", { path: ".env" }, []);
|
|
64
|
+
expect(result.values).toEqual([".env"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("does not expand the '*' fallback", () => {
|
|
68
|
+
const result = normalizeInput("path", {}, []);
|
|
69
|
+
expect(result.values).toEqual(["*"]);
|
|
70
|
+
});
|
|
28
71
|
});
|
|
29
72
|
|
|
30
73
|
describe("special / external_directory", () => {
|
|
@@ -49,10 +92,33 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
49
92
|
expect(result.values).toEqual(["*"]);
|
|
50
93
|
});
|
|
51
94
|
|
|
95
|
+
it("falls back to '*' when path is an empty string", () => {
|
|
96
|
+
const result = normalizeInput("external_directory", { path: "" }, []);
|
|
97
|
+
expect(result.values).toEqual(["*"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
52
100
|
it("handles null input", () => {
|
|
53
101
|
const result = normalizeInput("external_directory", null, []);
|
|
54
102
|
expect(result.values).toEqual(["*"]);
|
|
55
103
|
});
|
|
104
|
+
|
|
105
|
+
it("expands ~/... path value to absolute home path", () => {
|
|
106
|
+
const result = normalizeInput(
|
|
107
|
+
"external_directory",
|
|
108
|
+
{ path: "~/dev/project" },
|
|
109
|
+
[],
|
|
110
|
+
);
|
|
111
|
+
expect(result.values).toEqual([join("/mock/home", "dev/project")]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("expands $HOME/... path value to absolute home path", () => {
|
|
115
|
+
const result = normalizeInput(
|
|
116
|
+
"external_directory",
|
|
117
|
+
{ path: "$HOME/dev/project" },
|
|
118
|
+
[],
|
|
119
|
+
);
|
|
120
|
+
expect(result.values).toEqual([join("/mock/home", "dev/project")]);
|
|
121
|
+
});
|
|
56
122
|
});
|
|
57
123
|
|
|
58
124
|
describe("skill", () => {
|
|
@@ -130,6 +196,16 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
130
196
|
const result = normalizeInput("edit", null, []);
|
|
131
197
|
expect(result.values).toEqual(["*"]);
|
|
132
198
|
});
|
|
199
|
+
|
|
200
|
+
it("expands ~/... path value to absolute home path", () => {
|
|
201
|
+
const result = normalizeInput("read", { path: "~/.ssh/config" }, []);
|
|
202
|
+
expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("expands $HOME/... path value to absolute home path", () => {
|
|
206
|
+
const result = normalizeInput("write", { path: "$HOME/.ssh/config" }, []);
|
|
207
|
+
expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
|
|
208
|
+
});
|
|
133
209
|
});
|
|
134
210
|
|
|
135
211
|
describe("extension tools (non-path-bearing)", () => {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { expect, test } from "vitest";
|
|
11
|
+
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
12
|
+
import { createPermissionSystemLogger } from "#src/logging";
|
|
13
|
+
|
|
14
|
+
test("Permission-system logger respects debug toggle and keeps review log enabled by default", () => {
|
|
15
|
+
const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-logs-"));
|
|
16
|
+
const logsDir = join(baseDir, "logs");
|
|
17
|
+
const debugLogPath = join(logsDir, "debug.jsonl");
|
|
18
|
+
const reviewLogPath = join(logsDir, "review.jsonl");
|
|
19
|
+
const config = { ...DEFAULT_EXTENSION_CONFIG };
|
|
20
|
+
const logger = createPermissionSystemLogger({
|
|
21
|
+
getConfig: () => config,
|
|
22
|
+
debugLogPath,
|
|
23
|
+
reviewLogPath,
|
|
24
|
+
ensureLogsDirectory: () => {
|
|
25
|
+
mkdirSync(logsDir, { recursive: true });
|
|
26
|
+
return undefined;
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const initialDebugWarning = logger.debug("debug.disabled", {
|
|
32
|
+
sample: true,
|
|
33
|
+
});
|
|
34
|
+
const reviewWarning = logger.review("permission_request.waiting", {
|
|
35
|
+
toolName: "write",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(initialDebugWarning).toBe(undefined);
|
|
39
|
+
expect(reviewWarning).toBe(undefined);
|
|
40
|
+
expect(existsSync(debugLogPath)).toBe(false);
|
|
41
|
+
expect(existsSync(reviewLogPath)).toBe(true);
|
|
42
|
+
|
|
43
|
+
config.debugLog = true;
|
|
44
|
+
const enabledDebugWarning = logger.debug("debug.enabled", { sample: true });
|
|
45
|
+
expect(enabledDebugWarning).toBe(undefined);
|
|
46
|
+
expect(existsSync(debugLogPath)).toBe(true);
|
|
47
|
+
expect(readFileSync(debugLogPath, "utf8")).toMatch(/debug\.enabled/);
|
|
48
|
+
} finally {
|
|
49
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
50
|
+
}
|
|
51
|
+
});
|
package/test/path-utils.test.ts
CHANGED
|
@@ -47,6 +47,16 @@ describe("normalizePathForComparison", () => {
|
|
|
47
47
|
);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
test("expands bare $HOME to homedir", () => {
|
|
51
|
+
expect(normalizePathForComparison("$HOME", cwd)).toBe("/mock/home");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("expands $HOME/... to homedir-relative path", () => {
|
|
55
|
+
expect(normalizePathForComparison("$HOME/.ssh/config", cwd)).toBe(
|
|
56
|
+
join("/mock/home", ".ssh/config"),
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
50
60
|
test("strips leading @ before resolving", () => {
|
|
51
61
|
expect(normalizePathForComparison("@/usr/local/bin", cwd)).toBe(
|
|
52
62
|
"/usr/local/bin",
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { tmpdir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
1
3
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
2
4
|
import {
|
|
5
|
+
createPermissionForwardingLocation,
|
|
6
|
+
isForwardedPermissionRequestForSession,
|
|
3
7
|
resolvePermissionForwardingTargetSessionId,
|
|
4
8
|
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
|
|
5
9
|
SUBAGENT_PARENT_SESSION_ENV_KEY,
|
|
@@ -240,3 +244,72 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
|
|
|
240
244
|
).toBe("parent-from-env");
|
|
241
245
|
});
|
|
242
246
|
});
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Moved from permission-system.test.ts catch-all (#342)
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
test("Permission forwarding resolves the parent interactive session from subagent runtime env", () => {
|
|
253
|
+
const targetSessionId = resolvePermissionForwardingTargetSessionId({
|
|
254
|
+
hasUI: false,
|
|
255
|
+
isSubagent: true,
|
|
256
|
+
currentSessionId: "child-session",
|
|
257
|
+
env: {
|
|
258
|
+
PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-session",
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect(targetSessionId).toBe("parent-session");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("Permission forwarding does not guess a target session when subagent runtime env is missing", () => {
|
|
266
|
+
const targetSessionId = resolvePermissionForwardingTargetSessionId({
|
|
267
|
+
hasUI: false,
|
|
268
|
+
isSubagent: true,
|
|
269
|
+
currentSessionId: "child-session",
|
|
270
|
+
env: {},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
expect(targetSessionId).toBe(null);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("Permission forwarding uses session-scoped directories per interactive session", () => {
|
|
277
|
+
const forwardingRoot = join(tmpdir(), "pi-permission-system-forwarding-root");
|
|
278
|
+
const sessionA = createPermissionForwardingLocation(
|
|
279
|
+
forwardingRoot,
|
|
280
|
+
"session-a",
|
|
281
|
+
);
|
|
282
|
+
const sessionB = createPermissionForwardingLocation(
|
|
283
|
+
forwardingRoot,
|
|
284
|
+
"session-b",
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
expect(sessionA.sessionRootDir).not.toBe(sessionB.sessionRootDir);
|
|
288
|
+
expect(sessionA.requestsDir).not.toBe(sessionB.requestsDir);
|
|
289
|
+
expect(sessionA.responsesDir).not.toBe(sessionB.responsesDir);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("Permission forwarding request routing only matches the intended UI session", () => {
|
|
293
|
+
expect(
|
|
294
|
+
isForwardedPermissionRequestForSession(
|
|
295
|
+
{ targetSessionId: "session-a" },
|
|
296
|
+
"session-a",
|
|
297
|
+
),
|
|
298
|
+
).toBe(true);
|
|
299
|
+
expect(
|
|
300
|
+
isForwardedPermissionRequestForSession(
|
|
301
|
+
{ targetSessionId: "session-a" },
|
|
302
|
+
"session-b",
|
|
303
|
+
),
|
|
304
|
+
).toBe(false);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("Permission forwarding rejects unresolved sentinel session ids", () => {
|
|
308
|
+
const targetSessionId = resolvePermissionForwardingTargetSessionId({
|
|
309
|
+
hasUI: true,
|
|
310
|
+
isSubagent: false,
|
|
311
|
+
currentSessionId: "unknown",
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
expect(targetSessionId).toBe(null);
|
|
315
|
+
});
|