@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
|
@@ -1,2785 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
existsSync,
|
|
3
|
-
mkdirSync,
|
|
4
|
-
mkdtempSync,
|
|
5
|
-
readFileSync,
|
|
6
|
-
rmSync,
|
|
7
|
-
writeFileSync,
|
|
8
|
-
} from "node:fs";
|
|
9
|
-
import { homedir, tmpdir } from "node:os";
|
|
10
|
-
import { dirname, join, resolve } from "node:path";
|
|
11
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { expect, test } from "vitest";
|
|
13
|
-
import {
|
|
14
|
-
createActiveToolsCacheKey,
|
|
15
|
-
createBeforeAgentStartPromptStateKey,
|
|
16
|
-
shouldApplyCachedAgentStartState,
|
|
17
|
-
} from "#src/before-agent-start-cache";
|
|
18
|
-
import { getGlobalConfigPath } from "#src/config-paths";
|
|
19
|
-
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
20
|
-
import piPermissionSystemExtension from "#src/index";
|
|
21
|
-
import { createPermissionSystemLogger } from "#src/logging";
|
|
22
|
-
import {
|
|
23
|
-
createPermissionForwardingLocation,
|
|
24
|
-
isForwardedPermissionRequestForSession,
|
|
25
|
-
resolvePermissionForwardingTargetSessionId,
|
|
26
|
-
SUBAGENT_ENV_HINT_KEYS,
|
|
27
|
-
SUBAGENT_PARENT_SESSION_ENV_KEY,
|
|
28
|
-
} from "#src/permission-forwarding";
|
|
29
|
-
import { PermissionManager } from "#src/permission-manager";
|
|
30
|
-
import {
|
|
31
|
-
findSkillPathMatch,
|
|
32
|
-
parseAllSkillPromptSections,
|
|
33
|
-
resolveSkillPromptEntries,
|
|
34
|
-
} from "#src/skill-prompt-sanitizer";
|
|
35
|
-
import { getPermissionSystemStatus } from "#src/status";
|
|
36
|
-
import { sanitizeAvailableToolsSection } from "#src/system-prompt-sanitizer";
|
|
37
|
-
import {
|
|
38
|
-
checkRequestedToolRegistration,
|
|
39
|
-
getToolNameFromValue,
|
|
40
|
-
} from "#src/tool-registry";
|
|
41
|
-
import type {
|
|
42
|
-
PermissionCheckResult,
|
|
43
|
-
PermissionState,
|
|
44
|
-
ScopeConfig,
|
|
45
|
-
} from "#src/types";
|
|
46
|
-
import {
|
|
47
|
-
canResolveAskPermissionRequest,
|
|
48
|
-
shouldAutoApprovePermissionState,
|
|
49
|
-
} from "#src/yolo-mode";
|
|
50
|
-
import { type FakePi, makeFakePi } from "#test/helpers/make-fake-pi";
|
|
51
|
-
import {
|
|
52
|
-
type CreateManagerOptions,
|
|
53
|
-
createManager,
|
|
54
|
-
} from "#test/helpers/manager-harness";
|
|
55
|
-
|
|
56
|
-
type ExtensionHarness = {
|
|
57
|
-
baseDir: string;
|
|
58
|
-
cwd: string;
|
|
59
|
-
pi: FakePi;
|
|
60
|
-
prompts: string[];
|
|
61
|
-
cleanup: () => Promise<void>;
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
type ExtensionHarnessOptions = {
|
|
65
|
-
cwd?: string;
|
|
66
|
-
hasUI?: boolean;
|
|
67
|
-
selectResponse?: string;
|
|
68
|
-
inputResponse?: string;
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
const INHERITED_SUBAGENT_ENV_KEYS = [
|
|
72
|
-
...SUBAGENT_ENV_HINT_KEYS,
|
|
73
|
-
// eslint-disable-next-line @typescript-eslint/no-deprecated -- test uses deprecated alias intentionally
|
|
74
|
-
SUBAGENT_PARENT_SESSION_ENV_KEY,
|
|
75
|
-
] as const;
|
|
76
|
-
|
|
77
|
-
async function withIsolatedSubagentEnv<T>(
|
|
78
|
-
operation: () => Promise<T>,
|
|
79
|
-
): Promise<T> {
|
|
80
|
-
const originalValues = new Map<string, string | undefined>();
|
|
81
|
-
for (const key of INHERITED_SUBAGENT_ENV_KEYS) {
|
|
82
|
-
originalValues.set(key, process.env[key]);
|
|
83
|
-
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- process.env cleanup requires dynamic delete
|
|
84
|
-
delete process.env[key];
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
try {
|
|
88
|
-
return await operation();
|
|
89
|
-
} finally {
|
|
90
|
-
for (const [key, value] of originalValues.entries()) {
|
|
91
|
-
if (value === undefined) {
|
|
92
|
-
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- process.env cleanup requires dynamic delete
|
|
93
|
-
delete process.env[key];
|
|
94
|
-
} else {
|
|
95
|
-
process.env[key] = value;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function createToolCallHarness(
|
|
102
|
-
config: ScopeConfig,
|
|
103
|
-
toolNames: readonly string[],
|
|
104
|
-
options: ExtensionHarnessOptions = {},
|
|
105
|
-
): ExtensionHarness {
|
|
106
|
-
const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-runtime-"));
|
|
107
|
-
const cwd = options.cwd ?? baseDir;
|
|
108
|
-
const prompts: string[] = [];
|
|
109
|
-
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
110
|
-
const globalConfigPath = getGlobalConfigPath(baseDir);
|
|
111
|
-
mkdirSync(join(baseDir, "agents"), { recursive: true });
|
|
112
|
-
mkdirSync(dirname(globalConfigPath), { recursive: true });
|
|
113
|
-
mkdirSync(cwd, { recursive: true });
|
|
114
|
-
writeFileSync(
|
|
115
|
-
globalConfigPath,
|
|
116
|
-
`${JSON.stringify({ ...DEFAULT_EXTENSION_CONFIG, ...config }, null, 2)}\n`,
|
|
117
|
-
"utf8",
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
const pi = makeFakePi({ toolNames });
|
|
121
|
-
process.env.PI_CODING_AGENT_DIR = baseDir;
|
|
122
|
-
try {
|
|
123
|
-
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
|
|
124
|
-
} finally {
|
|
125
|
-
if (originalAgentDir === undefined) {
|
|
126
|
-
delete process.env.PI_CODING_AGENT_DIR;
|
|
127
|
-
} else {
|
|
128
|
-
process.env.PI_CODING_AGENT_DIR = originalAgentDir;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
return {
|
|
133
|
-
baseDir,
|
|
134
|
-
cwd,
|
|
135
|
-
pi,
|
|
136
|
-
prompts,
|
|
137
|
-
cleanup: async (): Promise<void> => {
|
|
138
|
-
await pi.fire(
|
|
139
|
-
"session_shutdown",
|
|
140
|
-
{},
|
|
141
|
-
createMockContext(cwd, prompts, options),
|
|
142
|
-
);
|
|
143
|
-
rmSync(baseDir, { recursive: true, force: true });
|
|
144
|
-
},
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function createMockContext(
|
|
149
|
-
cwd: string,
|
|
150
|
-
prompts: string[],
|
|
151
|
-
options: ExtensionHarnessOptions = {},
|
|
152
|
-
): Record<string, unknown> {
|
|
153
|
-
return {
|
|
154
|
-
cwd,
|
|
155
|
-
hasUI: options.hasUI === true,
|
|
156
|
-
sessionManager: {
|
|
157
|
-
getEntries: (): unknown[] => [],
|
|
158
|
-
getSessionId: (): string => "test-session",
|
|
159
|
-
getSessionDir: (): string => cwd,
|
|
160
|
-
},
|
|
161
|
-
ui: {
|
|
162
|
-
notify: (): void => {},
|
|
163
|
-
setStatus: (): void => {},
|
|
164
|
-
select: async (title: string): Promise<string | undefined> => {
|
|
165
|
-
prompts.push(title);
|
|
166
|
-
return options.selectResponse ?? "Yes";
|
|
167
|
-
},
|
|
168
|
-
input: async (): Promise<string | undefined> => options.inputResponse,
|
|
169
|
-
},
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
async function runToolCall(
|
|
174
|
-
harness: ExtensionHarness,
|
|
175
|
-
event: Record<string, unknown>,
|
|
176
|
-
options: ExtensionHarnessOptions = {},
|
|
177
|
-
): Promise<Record<string, unknown>> {
|
|
178
|
-
const result = await withIsolatedSubagentEnv(async () =>
|
|
179
|
-
harness.pi.fire(
|
|
180
|
-
"tool_call",
|
|
181
|
-
event,
|
|
182
|
-
createMockContext(harness.cwd, harness.prompts, options),
|
|
183
|
-
),
|
|
184
|
-
);
|
|
185
|
-
return (result as Record<string, unknown> | undefined) ?? {};
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
test("Yolo mode only auto-approves ask-state permissions", () => {
|
|
189
|
-
expect(
|
|
190
|
-
shouldAutoApprovePermissionState("ask", DEFAULT_EXTENSION_CONFIG),
|
|
191
|
-
).toBe(false);
|
|
192
|
-
expect(
|
|
193
|
-
shouldAutoApprovePermissionState("ask", {
|
|
194
|
-
...DEFAULT_EXTENSION_CONFIG,
|
|
195
|
-
yoloMode: true,
|
|
196
|
-
}),
|
|
197
|
-
).toBe(true);
|
|
198
|
-
expect(
|
|
199
|
-
shouldAutoApprovePermissionState("deny", {
|
|
200
|
-
...DEFAULT_EXTENSION_CONFIG,
|
|
201
|
-
yoloMode: true,
|
|
202
|
-
}),
|
|
203
|
-
).toBe(false);
|
|
204
|
-
expect(
|
|
205
|
-
shouldAutoApprovePermissionState("allow", {
|
|
206
|
-
...DEFAULT_EXTENSION_CONFIG,
|
|
207
|
-
yoloMode: true,
|
|
208
|
-
}),
|
|
209
|
-
).toBe(false);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
test("Yolo mode resolves ask permissions without UI or delegation forwarding", () => {
|
|
213
|
-
expect(
|
|
214
|
-
canResolveAskPermissionRequest({
|
|
215
|
-
config: DEFAULT_EXTENSION_CONFIG,
|
|
216
|
-
hasUI: false,
|
|
217
|
-
isSubagent: false,
|
|
218
|
-
}),
|
|
219
|
-
).toBe(false);
|
|
220
|
-
expect(
|
|
221
|
-
canResolveAskPermissionRequest({
|
|
222
|
-
config: { ...DEFAULT_EXTENSION_CONFIG, yoloMode: true },
|
|
223
|
-
hasUI: false,
|
|
224
|
-
isSubagent: false,
|
|
225
|
-
}),
|
|
226
|
-
).toBe(true);
|
|
227
|
-
expect(
|
|
228
|
-
canResolveAskPermissionRequest({
|
|
229
|
-
config: DEFAULT_EXTENSION_CONFIG,
|
|
230
|
-
hasUI: false,
|
|
231
|
-
isSubagent: true,
|
|
232
|
-
}),
|
|
233
|
-
).toBe(true);
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
test("Permission-system status is only exposed when yolo mode is enabled", () => {
|
|
237
|
-
expect(getPermissionSystemStatus(DEFAULT_EXTENSION_CONFIG)).toBe(undefined);
|
|
238
|
-
expect(
|
|
239
|
-
getPermissionSystemStatus({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
|
|
240
|
-
).toBe("yolo");
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
test("System prompt sanitizer removes the Available tools section and surrounding boilerplate", () => {
|
|
244
|
-
const prompt = [
|
|
245
|
-
"Available tools:",
|
|
246
|
-
"- read: Read file contents",
|
|
247
|
-
"- mcp: Discover, inspect, and call MCP tools across configured servers",
|
|
248
|
-
"",
|
|
249
|
-
"In addition to the tools above, you may have access to other custom tools depending on the project.",
|
|
250
|
-
"",
|
|
251
|
-
"Guidelines:",
|
|
252
|
-
"- Use mcp for MCP discovery first: search by capability, describe one exact tool name, then call it.",
|
|
253
|
-
"- Be concise in your responses",
|
|
254
|
-
].join("\n");
|
|
255
|
-
|
|
256
|
-
const result = sanitizeAvailableToolsSection(prompt, ["read", "mcp"]);
|
|
257
|
-
|
|
258
|
-
expect(result.removed).toBe(true);
|
|
259
|
-
expect(result.prompt).not.toContain("Available tools:");
|
|
260
|
-
expect(result.prompt).not.toContain("In addition to the tools above");
|
|
261
|
-
expect(result.prompt).toMatch(/Guidelines:/);
|
|
262
|
-
expect(result.prompt).toMatch(/Use mcp for MCP discovery first/i);
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
test("System prompt sanitizer removes denied tool guidelines while keeping global guidance", () => {
|
|
266
|
-
const prompt = [
|
|
267
|
-
"Guidelines:",
|
|
268
|
-
"- Use task when work SHOULD be delegated to one or more specialized agents instead of handled entirely in the current session.",
|
|
269
|
-
"- Use mcp for MCP discovery first: search by capability, describe one exact tool name, then call it.",
|
|
270
|
-
"- Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)",
|
|
271
|
-
"- Be concise in your responses",
|
|
272
|
-
"- Show file paths clearly when working with files",
|
|
273
|
-
].join("\n");
|
|
274
|
-
|
|
275
|
-
const result = sanitizeAvailableToolsSection(prompt, ["bash", "grep", "mcp"]);
|
|
276
|
-
|
|
277
|
-
expect(result.removed).toBe(true);
|
|
278
|
-
expect(result.prompt).not.toContain("Use task when work SHOULD");
|
|
279
|
-
expect(result.prompt).toMatch(/Use mcp for MCP discovery first/i);
|
|
280
|
-
expect(result.prompt).toMatch(/Prefer grep\/find\/ls tools over bash/i);
|
|
281
|
-
expect(result.prompt).toMatch(/Be concise in your responses/);
|
|
282
|
-
expect(result.prompt).toMatch(
|
|
283
|
-
/Show file paths clearly when working with files/,
|
|
284
|
-
);
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
test("System prompt sanitizer removes inactive built-in write guidance", () => {
|
|
288
|
-
const prompt = [
|
|
289
|
-
"Guidelines:",
|
|
290
|
-
"- Use write only for new files or complete rewrites",
|
|
291
|
-
"- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did",
|
|
292
|
-
"- Be concise in your responses",
|
|
293
|
-
].join("\n");
|
|
294
|
-
|
|
295
|
-
const result = sanitizeAvailableToolsSection(prompt, ["read"]);
|
|
296
|
-
|
|
297
|
-
expect(result.removed).toBe(true);
|
|
298
|
-
expect(result.prompt).not.toContain(
|
|
299
|
-
"Use write only for new files or complete rewrites",
|
|
300
|
-
);
|
|
301
|
-
expect(result.prompt).not.toContain(
|
|
302
|
-
"do NOT use cat or bash to display what you did",
|
|
303
|
-
);
|
|
304
|
-
expect(result.prompt).toMatch(/Be concise in your responses/);
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
test("Before-agent-start cache dedupes unchanged active-tool exposure and prompt state", () => {
|
|
308
|
-
const allowedTools = ["read", "mcp"];
|
|
309
|
-
const activeToolsKey = createActiveToolsCacheKey(allowedTools);
|
|
310
|
-
const promptStateKey = createBeforeAgentStartPromptStateKey({
|
|
311
|
-
agentName: "code",
|
|
312
|
-
cwd: "C:/workspace/project",
|
|
313
|
-
permissionStamp: "permissions-v1",
|
|
314
|
-
systemPrompt: "Available tools:\n- read\n- mcp",
|
|
315
|
-
allowedToolNames: allowedTools,
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
expect(shouldApplyCachedAgentStartState(null, activeToolsKey)).toBe(true);
|
|
319
|
-
expect(shouldApplyCachedAgentStartState(activeToolsKey, activeToolsKey)).toBe(
|
|
320
|
-
false,
|
|
321
|
-
);
|
|
322
|
-
expect(shouldApplyCachedAgentStartState(null, promptStateKey)).toBe(true);
|
|
323
|
-
expect(shouldApplyCachedAgentStartState(promptStateKey, promptStateKey)).toBe(
|
|
324
|
-
false,
|
|
325
|
-
);
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
test("Before-agent-start prompt cache invalidates on permission changes while runtime enforcement stays authoritative", () => {
|
|
329
|
-
const { manager, globalConfigPath, cleanup } = createManager({
|
|
330
|
-
permission: { "*": "allow", write: "deny" },
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
try {
|
|
334
|
-
const baselineStamp = manager.getPolicyCacheStamp();
|
|
335
|
-
const baselineKey = createBeforeAgentStartPromptStateKey({
|
|
336
|
-
agentName: null,
|
|
337
|
-
cwd: "C:/workspace/project",
|
|
338
|
-
permissionStamp: baselineStamp,
|
|
339
|
-
systemPrompt: "Available tools:\n- read\n- write",
|
|
340
|
-
allowedToolNames: ["read"],
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
expect(shouldApplyCachedAgentStartState(baselineKey, baselineKey)).toBe(
|
|
344
|
-
false,
|
|
345
|
-
);
|
|
346
|
-
expect(manager.checkPermission("write", {}, undefined).state).toBe("deny");
|
|
347
|
-
|
|
348
|
-
const updatedConfig = `${JSON.stringify(
|
|
349
|
-
{ permission: { "*": "allow", write: "allow" } },
|
|
350
|
-
null,
|
|
351
|
-
2,
|
|
352
|
-
)}\n`;
|
|
353
|
-
|
|
354
|
-
let updatedStamp = baselineStamp;
|
|
355
|
-
for (
|
|
356
|
-
let attempt = 0;
|
|
357
|
-
attempt < 10 && updatedStamp === baselineStamp;
|
|
358
|
-
attempt += 1
|
|
359
|
-
) {
|
|
360
|
-
const waitUntil = Date.now() + 2;
|
|
361
|
-
while (Date.now() < waitUntil) {
|
|
362
|
-
// Wait for the filesystem timestamp granularity to advance.
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
writeFileSync(globalConfigPath, updatedConfig, "utf8");
|
|
366
|
-
updatedStamp = manager.getPolicyCacheStamp();
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
expect(updatedStamp).not.toBe(baselineStamp);
|
|
370
|
-
|
|
371
|
-
const invalidatedKey = createBeforeAgentStartPromptStateKey({
|
|
372
|
-
agentName: null,
|
|
373
|
-
cwd: "C:/workspace/project",
|
|
374
|
-
permissionStamp: updatedStamp,
|
|
375
|
-
systemPrompt: "Available tools:\n- read\n- write",
|
|
376
|
-
allowedToolNames: ["read", "write"],
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
expect(shouldApplyCachedAgentStartState(baselineKey, invalidatedKey)).toBe(
|
|
380
|
-
true,
|
|
381
|
-
);
|
|
382
|
-
expect(manager.checkPermission("write", {}, undefined).state).toBe("allow");
|
|
383
|
-
} finally {
|
|
384
|
-
cleanup();
|
|
385
|
-
}
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
test("Permission-system logger respects debug toggle and keeps review log enabled by default", () => {
|
|
389
|
-
const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-logs-"));
|
|
390
|
-
const logsDir = join(baseDir, "logs");
|
|
391
|
-
const debugLogPath = join(logsDir, "debug.jsonl");
|
|
392
|
-
const reviewLogPath = join(logsDir, "review.jsonl");
|
|
393
|
-
const config = { ...DEFAULT_EXTENSION_CONFIG };
|
|
394
|
-
const logger = createPermissionSystemLogger({
|
|
395
|
-
getConfig: () => config,
|
|
396
|
-
debugLogPath,
|
|
397
|
-
reviewLogPath,
|
|
398
|
-
ensureLogsDirectory: () => {
|
|
399
|
-
mkdirSync(logsDir, { recursive: true });
|
|
400
|
-
return undefined;
|
|
401
|
-
},
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
try {
|
|
405
|
-
const initialDebugWarning = logger.debug("debug.disabled", {
|
|
406
|
-
sample: true,
|
|
407
|
-
});
|
|
408
|
-
const reviewWarning = logger.review("permission_request.waiting", {
|
|
409
|
-
toolName: "write",
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
expect(initialDebugWarning).toBe(undefined);
|
|
413
|
-
expect(reviewWarning).toBe(undefined);
|
|
414
|
-
expect(existsSync(debugLogPath)).toBe(false);
|
|
415
|
-
expect(existsSync(reviewLogPath)).toBe(true);
|
|
416
|
-
|
|
417
|
-
config.debugLog = true;
|
|
418
|
-
const enabledDebugWarning = logger.debug("debug.enabled", { sample: true });
|
|
419
|
-
expect(enabledDebugWarning).toBe(undefined);
|
|
420
|
-
expect(existsSync(debugLogPath)).toBe(true);
|
|
421
|
-
expect(readFileSync(debugLogPath, "utf8")).toMatch(/debug\.enabled/);
|
|
422
|
-
} finally {
|
|
423
|
-
rmSync(baseDir, { recursive: true, force: true });
|
|
424
|
-
}
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
test("PermissionManager canonical built-in permission checking", () => {
|
|
428
|
-
const { manager, cleanup } = createManager({
|
|
429
|
-
permission: { "*": "deny", read: "allow" },
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
try {
|
|
433
|
-
const readResult = manager.checkPermission("read", {});
|
|
434
|
-
expect(readResult.state).toBe("allow");
|
|
435
|
-
expect(readResult.source).toBe("tool");
|
|
436
|
-
|
|
437
|
-
const writeResult = manager.checkPermission("write", {});
|
|
438
|
-
expect(writeResult.state).toBe("deny");
|
|
439
|
-
expect(writeResult.source).toBe("tool");
|
|
440
|
-
} finally {
|
|
441
|
-
cleanup();
|
|
442
|
-
}
|
|
443
|
-
});
|
|
444
|
-
|
|
445
|
-
test("multiline bash command resolves to allow via universal fallback", () => {
|
|
446
|
-
// Regression test for #73: node -e "..." with embedded newlines was
|
|
447
|
-
// falling through to the hard-coded 'ask' default because wildcardMatch
|
|
448
|
-
// used /^.*$/ (no dotAll), which does not match '\n'.
|
|
449
|
-
const { manager, cleanup } = createManager({
|
|
450
|
-
permission: {
|
|
451
|
-
"*": "allow",
|
|
452
|
-
bash: { "rm -rf *": "deny", "sudo *": "ask" },
|
|
453
|
-
},
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
try {
|
|
457
|
-
const command =
|
|
458
|
-
"node -e \"\nimport('x').then(() => {\n console.log('done');\n});\n\"";
|
|
459
|
-
const result = manager.checkPermission("bash", { command });
|
|
460
|
-
expect(result.state).toBe("allow");
|
|
461
|
-
} finally {
|
|
462
|
-
cleanup();
|
|
463
|
-
}
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
test("Bash specific deny patterns override catch-all within the same config", () => {
|
|
467
|
-
// In the flat format, patterns within a surface map are ordered by insertion.
|
|
468
|
-
// Last-match-wins means specific patterns placed AFTER the catch-all override it.
|
|
469
|
-
const { manager, cleanup } = createManager({
|
|
470
|
-
permission: {
|
|
471
|
-
"*": "ask",
|
|
472
|
-
bash: { "*": "allow", "rm -rf *": "deny" },
|
|
473
|
-
},
|
|
474
|
-
});
|
|
475
|
-
|
|
476
|
-
try {
|
|
477
|
-
const denied = manager.checkPermission("bash", { command: "rm -rf build" });
|
|
478
|
-
expect(denied.state).toBe("deny");
|
|
479
|
-
expect(denied.source).toBe("bash");
|
|
480
|
-
expect(denied.matchedPattern).toBe("rm -rf *");
|
|
481
|
-
|
|
482
|
-
const allowed = manager.checkPermission("bash", { command: "echo hello" });
|
|
483
|
-
expect(allowed.state).toBe("allow");
|
|
484
|
-
expect(allowed.source).toBe("bash");
|
|
485
|
-
expect(allowed.matchedPattern).toBe("*");
|
|
486
|
-
} finally {
|
|
487
|
-
cleanup();
|
|
488
|
-
}
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
test("MCP wildcard matching uses the registered mcp tool", () => {
|
|
492
|
-
const { manager, cleanup } = createManager({
|
|
493
|
-
permission: {
|
|
494
|
-
"*": "ask",
|
|
495
|
-
mcp: { "*": "deny", "research_*": "ask", "research_query-*": "allow" },
|
|
496
|
-
},
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
try {
|
|
500
|
-
const queryDocs = manager.checkPermission("mcp", {
|
|
501
|
-
tool: "research:query-docs",
|
|
502
|
-
});
|
|
503
|
-
expect(queryDocs.state).toBe("allow");
|
|
504
|
-
expect(queryDocs.source).toBe("mcp");
|
|
505
|
-
expect(queryDocs.matchedPattern).toBe("research_query-*");
|
|
506
|
-
expect(queryDocs.target).toBe("research_query-docs");
|
|
507
|
-
|
|
508
|
-
const resolve2 = manager.checkPermission("mcp", {
|
|
509
|
-
tool: "research:resolve-context",
|
|
510
|
-
});
|
|
511
|
-
expect(resolve2.state).toBe("ask");
|
|
512
|
-
expect(resolve2.matchedPattern).toBe("research_*");
|
|
513
|
-
expect(resolve2.target).toBe("research_resolve-context");
|
|
514
|
-
|
|
515
|
-
const unknown = manager.checkPermission("mcp", { tool: "search:provider" });
|
|
516
|
-
expect(unknown.state).toBe("deny");
|
|
517
|
-
expect(unknown.matchedPattern).toBe("*");
|
|
518
|
-
expect(unknown.target).toBe("search_provider");
|
|
519
|
-
} finally {
|
|
520
|
-
cleanup();
|
|
521
|
-
}
|
|
522
|
-
});
|
|
523
|
-
|
|
524
|
-
test("Arbitrary extension tools use exact-name tool permissions instead of MCP fallback", () => {
|
|
525
|
-
const { manager, cleanup } = createManager({
|
|
526
|
-
permission: {
|
|
527
|
-
"*": "deny",
|
|
528
|
-
third_party_tool: "allow",
|
|
529
|
-
mcp: { "*": "deny" },
|
|
530
|
-
},
|
|
531
|
-
});
|
|
532
|
-
|
|
533
|
-
try {
|
|
534
|
-
const allowed = manager.checkPermission("third_party_tool", {});
|
|
535
|
-
expect(allowed.state).toBe("allow");
|
|
536
|
-
expect(allowed.source).toBe("tool");
|
|
537
|
-
|
|
538
|
-
// another_extension_tool has no explicit rule — falls through to the
|
|
539
|
-
// universal default (permission["*"] = "deny") with source "default".
|
|
540
|
-
const fallback = manager.checkPermission("another_extension_tool", {});
|
|
541
|
-
expect(fallback.state).toBe("deny");
|
|
542
|
-
expect(fallback.source).toBe("default");
|
|
543
|
-
} finally {
|
|
544
|
-
cleanup();
|
|
545
|
-
}
|
|
546
|
-
});
|
|
547
|
-
|
|
548
|
-
test("Skill permission matching", () => {
|
|
549
|
-
const { manager, cleanup } = createManager({
|
|
550
|
-
permission: {
|
|
551
|
-
"*": "ask",
|
|
552
|
-
skill: {
|
|
553
|
-
"*": "ask",
|
|
554
|
-
"web-*": "deny",
|
|
555
|
-
"requesting-code-review": "allow",
|
|
556
|
-
},
|
|
557
|
-
},
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
try {
|
|
561
|
-
const allowed = manager.checkPermission("skill", {
|
|
562
|
-
name: "requesting-code-review",
|
|
563
|
-
});
|
|
564
|
-
expect(allowed.state).toBe("allow");
|
|
565
|
-
expect(allowed.matchedPattern).toBe("requesting-code-review");
|
|
566
|
-
expect(allowed.source).toBe("skill");
|
|
567
|
-
|
|
568
|
-
const denied = manager.checkPermission("skill", {
|
|
569
|
-
name: "web-design-guidelines",
|
|
570
|
-
});
|
|
571
|
-
expect(denied.state).toBe("deny");
|
|
572
|
-
expect(denied.matchedPattern).toBe("web-*");
|
|
573
|
-
|
|
574
|
-
const fallback = manager.checkPermission("skill", {
|
|
575
|
-
name: "unknown-skill",
|
|
576
|
-
});
|
|
577
|
-
expect(fallback.state).toBe("ask");
|
|
578
|
-
expect(fallback.matchedPattern).toBe("*");
|
|
579
|
-
} finally {
|
|
580
|
-
cleanup();
|
|
581
|
-
}
|
|
582
|
-
});
|
|
583
|
-
|
|
584
|
-
test("MCP proxy tool infers server-prefixed aliases from configured server names", () => {
|
|
585
|
-
const { manager, cleanup } = createManager(
|
|
586
|
-
{
|
|
587
|
-
permission: {
|
|
588
|
-
"*": "ask",
|
|
589
|
-
mcp: { "exa_*": "deny", exa_get_code_context_exa: "allow" },
|
|
590
|
-
},
|
|
591
|
-
},
|
|
592
|
-
{},
|
|
593
|
-
{
|
|
594
|
-
mcpServerNames: ["exa"],
|
|
595
|
-
},
|
|
596
|
-
);
|
|
597
|
-
|
|
598
|
-
try {
|
|
599
|
-
const result = manager.checkPermission("mcp", {
|
|
600
|
-
tool: "get_code_context_exa",
|
|
601
|
-
});
|
|
602
|
-
expect(result.state).toBe("allow");
|
|
603
|
-
expect(result.source).toBe("mcp");
|
|
604
|
-
expect(result.matchedPattern).toBe("exa_get_code_context_exa");
|
|
605
|
-
expect(result.target).toBe("exa_get_code_context_exa");
|
|
606
|
-
} finally {
|
|
607
|
-
cleanup();
|
|
608
|
-
}
|
|
609
|
-
});
|
|
610
|
-
|
|
611
|
-
test("MCP server names in settings.json are not used — only mcp.json is consulted", () => {
|
|
612
|
-
const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-test-"));
|
|
613
|
-
const globalConfigPath = join(baseDir, "pi-permissions.jsonc");
|
|
614
|
-
const mcpConfigPath = join(baseDir, "mcp.json");
|
|
615
|
-
const settingsJsonPath = join(baseDir, "settings.json");
|
|
616
|
-
const agentsDir = join(baseDir, "agents");
|
|
617
|
-
mkdirSync(agentsDir, { recursive: true });
|
|
618
|
-
|
|
619
|
-
const config: ScopeConfig = {
|
|
620
|
-
permission: { "*": "ask", mcp: { "legacy-server_*": "allow" } },
|
|
621
|
-
};
|
|
622
|
-
|
|
623
|
-
writeFileSync(
|
|
624
|
-
globalConfigPath,
|
|
625
|
-
`${JSON.stringify(config, null, 2)}\n`,
|
|
626
|
-
"utf8",
|
|
627
|
-
);
|
|
628
|
-
writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: {} }), "utf8");
|
|
629
|
-
writeFileSync(
|
|
630
|
-
settingsJsonPath,
|
|
631
|
-
JSON.stringify({ mcpServers: { "legacy-server": {} } }),
|
|
632
|
-
"utf8",
|
|
633
|
-
);
|
|
634
|
-
|
|
635
|
-
const manager = new PermissionManager({
|
|
636
|
-
globalConfigPath,
|
|
637
|
-
agentsDir,
|
|
638
|
-
globalMcpConfigPath: mcpConfigPath,
|
|
639
|
-
});
|
|
640
|
-
|
|
641
|
-
try {
|
|
642
|
-
const result = manager.checkPermission("mcp", {
|
|
643
|
-
tool: "some_tool_legacy-server",
|
|
644
|
-
});
|
|
645
|
-
expect(result.state).toBe("ask");
|
|
646
|
-
} finally {
|
|
647
|
-
rmSync(baseDir, { recursive: true, force: true });
|
|
648
|
-
}
|
|
649
|
-
});
|
|
650
|
-
|
|
651
|
-
test("MCP describe mode normalizes qualified tool names without duplicating server prefixes", () => {
|
|
652
|
-
const { manager, cleanup } = createManager(
|
|
653
|
-
{
|
|
654
|
-
permission: {
|
|
655
|
-
"*": "ask",
|
|
656
|
-
mcp: { "exa_*": "deny", exa_web_search_exa: "allow" },
|
|
657
|
-
},
|
|
658
|
-
},
|
|
659
|
-
{},
|
|
660
|
-
{
|
|
661
|
-
mcpServerNames: ["exa"],
|
|
662
|
-
},
|
|
663
|
-
);
|
|
664
|
-
|
|
665
|
-
try {
|
|
666
|
-
const result = manager.checkPermission("mcp", {
|
|
667
|
-
describe: "exa:web_search_exa",
|
|
668
|
-
server: "exa",
|
|
669
|
-
});
|
|
670
|
-
expect(result.state).toBe("allow");
|
|
671
|
-
expect(result.source).toBe("mcp");
|
|
672
|
-
expect(result.matchedPattern).toBe("exa_web_search_exa");
|
|
673
|
-
expect(result.target).toBe("exa_web_search_exa");
|
|
674
|
-
} finally {
|
|
675
|
-
cleanup();
|
|
676
|
-
}
|
|
677
|
-
});
|
|
678
|
-
|
|
679
|
-
test("Canonical tools map directly without legacy aliases", () => {
|
|
680
|
-
const { manager, cleanup } = createManager({
|
|
681
|
-
permission: { "*": "ask", find: "allow", ls: "deny" },
|
|
682
|
-
});
|
|
683
|
-
|
|
684
|
-
try {
|
|
685
|
-
const findResult = manager.checkPermission("find", {});
|
|
686
|
-
expect(findResult.state).toBe("allow");
|
|
687
|
-
expect(findResult.source).toBe("tool");
|
|
688
|
-
|
|
689
|
-
const lsResult = manager.checkPermission("ls", {});
|
|
690
|
-
expect(lsResult.state).toBe("deny");
|
|
691
|
-
expect(lsResult.source).toBe("tool");
|
|
692
|
-
} finally {
|
|
693
|
-
cleanup();
|
|
694
|
-
}
|
|
695
|
-
});
|
|
696
|
-
|
|
697
|
-
test("mcp catch-all acts as fallback for unmatched MCP targets", () => {
|
|
698
|
-
const { manager, cleanup } = createManager(
|
|
699
|
-
{
|
|
700
|
-
permission: { "*": "ask" },
|
|
701
|
-
},
|
|
702
|
-
{
|
|
703
|
-
reviewer: `---
|
|
704
|
-
name: reviewer
|
|
705
|
-
permission:
|
|
706
|
-
mcp: allow
|
|
707
|
-
---
|
|
708
|
-
`,
|
|
709
|
-
},
|
|
710
|
-
);
|
|
711
|
-
|
|
712
|
-
try {
|
|
713
|
-
const result = manager.checkPermission(
|
|
714
|
-
"mcp",
|
|
715
|
-
{ tool: "exa:web_search_exa" },
|
|
716
|
-
"reviewer",
|
|
717
|
-
);
|
|
718
|
-
expect(result.state).toBe("allow");
|
|
719
|
-
expect(result.source).toBe("mcp");
|
|
720
|
-
expect(result.target).toBe("exa_web_search_exa");
|
|
721
|
-
} finally {
|
|
722
|
-
cleanup();
|
|
723
|
-
}
|
|
724
|
-
});
|
|
725
|
-
|
|
726
|
-
test("specific MCP rules override mcp catch-all", () => {
|
|
727
|
-
const { manager, cleanup } = createManager(
|
|
728
|
-
{
|
|
729
|
-
permission: { "*": "ask" },
|
|
730
|
-
},
|
|
731
|
-
{
|
|
732
|
-
reviewer: `---
|
|
733
|
-
name: reviewer
|
|
734
|
-
permission:
|
|
735
|
-
mcp:
|
|
736
|
-
"*": allow
|
|
737
|
-
exa_web_search_exa: deny
|
|
738
|
-
---
|
|
739
|
-
`,
|
|
740
|
-
},
|
|
741
|
-
{
|
|
742
|
-
mcpServerNames: ["exa"],
|
|
743
|
-
},
|
|
744
|
-
);
|
|
745
|
-
|
|
746
|
-
try {
|
|
747
|
-
const result = manager.checkPermission(
|
|
748
|
-
"mcp",
|
|
749
|
-
{ tool: "web_search_exa" },
|
|
750
|
-
"reviewer",
|
|
751
|
-
);
|
|
752
|
-
expect(result.state).toBe("deny");
|
|
753
|
-
expect(result.source).toBe("mcp");
|
|
754
|
-
expect(result.matchedPattern).toBe("exa_web_search_exa");
|
|
755
|
-
expect(result.target).toBe("exa_web_search_exa");
|
|
756
|
-
} finally {
|
|
757
|
-
cleanup();
|
|
758
|
-
}
|
|
759
|
-
});
|
|
760
|
-
|
|
761
|
-
test("specific MCP rules still win when mcp catch-all is deny", () => {
|
|
762
|
-
const { manager, cleanup } = createManager(
|
|
763
|
-
{
|
|
764
|
-
permission: { "*": "ask" },
|
|
765
|
-
},
|
|
766
|
-
{
|
|
767
|
-
reviewer: `---
|
|
768
|
-
name: reviewer
|
|
769
|
-
permission:
|
|
770
|
-
mcp:
|
|
771
|
-
"*": deny
|
|
772
|
-
exa_web_search_exa: allow
|
|
773
|
-
---
|
|
774
|
-
`,
|
|
775
|
-
},
|
|
776
|
-
{
|
|
777
|
-
mcpServerNames: ["exa"],
|
|
778
|
-
},
|
|
779
|
-
);
|
|
780
|
-
|
|
781
|
-
try {
|
|
782
|
-
const allowed = manager.checkPermission(
|
|
783
|
-
"mcp",
|
|
784
|
-
{ tool: "web_search_exa" },
|
|
785
|
-
"reviewer",
|
|
786
|
-
);
|
|
787
|
-
expect(allowed.state).toBe("allow");
|
|
788
|
-
expect(allowed.source).toBe("mcp");
|
|
789
|
-
expect(allowed.matchedPattern).toBe("exa_web_search_exa");
|
|
790
|
-
expect(allowed.target).toBe("exa_web_search_exa");
|
|
791
|
-
|
|
792
|
-
const fallback = manager.checkPermission(
|
|
793
|
-
"mcp",
|
|
794
|
-
{ tool: "other_exa" },
|
|
795
|
-
"reviewer",
|
|
796
|
-
);
|
|
797
|
-
expect(fallback.state).toBe("deny");
|
|
798
|
-
expect(fallback.source).toBe("mcp");
|
|
799
|
-
expect(fallback.target).toBe("exa_other_exa");
|
|
800
|
-
} finally {
|
|
801
|
-
cleanup();
|
|
802
|
-
}
|
|
803
|
-
});
|
|
804
|
-
|
|
805
|
-
test("mcp catch-all in agent frontmatter overrides global default", () => {
|
|
806
|
-
const { manager, cleanup } = createManager(
|
|
807
|
-
{
|
|
808
|
-
permission: { "*": "deny" },
|
|
809
|
-
},
|
|
810
|
-
{
|
|
811
|
-
reviewer: `---
|
|
812
|
-
name: reviewer
|
|
813
|
-
permission:
|
|
814
|
-
mcp: allow
|
|
815
|
-
---
|
|
816
|
-
`,
|
|
817
|
-
},
|
|
818
|
-
);
|
|
819
|
-
|
|
820
|
-
try {
|
|
821
|
-
const readResult = manager.checkPermission("read", {}, "reviewer");
|
|
822
|
-
expect(readResult.state).toBe("deny");
|
|
823
|
-
expect(readResult.source).toBe("tool");
|
|
824
|
-
|
|
825
|
-
const mcpResult = manager.checkPermission(
|
|
826
|
-
"mcp",
|
|
827
|
-
{ tool: "exa:web_search_exa" },
|
|
828
|
-
"reviewer",
|
|
829
|
-
);
|
|
830
|
-
expect(mcpResult.state).toBe("allow");
|
|
831
|
-
expect(mcpResult.source).toBe("mcp");
|
|
832
|
-
} finally {
|
|
833
|
-
cleanup();
|
|
834
|
-
}
|
|
835
|
-
});
|
|
836
|
-
|
|
837
|
-
test("Agent frontmatter canonical tools resolve correctly", () => {
|
|
838
|
-
const { manager, cleanup } = createManager(
|
|
839
|
-
{
|
|
840
|
-
permission: { "*": "deny" },
|
|
841
|
-
},
|
|
842
|
-
{
|
|
843
|
-
reviewer: `---
|
|
844
|
-
name: reviewer
|
|
845
|
-
permission:
|
|
846
|
-
find: allow
|
|
847
|
-
ls: deny
|
|
848
|
-
---
|
|
849
|
-
`,
|
|
850
|
-
},
|
|
851
|
-
);
|
|
852
|
-
|
|
853
|
-
try {
|
|
854
|
-
const findResult = manager.checkPermission("find", {}, "reviewer");
|
|
855
|
-
expect(findResult.state).toBe("allow");
|
|
856
|
-
expect(findResult.source).toBe("tool");
|
|
857
|
-
|
|
858
|
-
const lsResult = manager.checkPermission("ls", {}, "reviewer");
|
|
859
|
-
expect(lsResult.state).toBe("deny");
|
|
860
|
-
expect(lsResult.source).toBe("tool");
|
|
861
|
-
} finally {
|
|
862
|
-
cleanup();
|
|
863
|
-
}
|
|
864
|
-
});
|
|
865
|
-
|
|
866
|
-
test("All surface names work in agent frontmatter flat permission format", () => {
|
|
867
|
-
const { manager, cleanup } = createManager(
|
|
868
|
-
{
|
|
869
|
-
permission: { "*": "deny" },
|
|
870
|
-
},
|
|
871
|
-
{
|
|
872
|
-
reviewer: `---
|
|
873
|
-
name: reviewer
|
|
874
|
-
permission:
|
|
875
|
-
find: allow
|
|
876
|
-
task: allow
|
|
877
|
-
mcp: allow
|
|
878
|
-
---
|
|
879
|
-
`,
|
|
880
|
-
},
|
|
881
|
-
);
|
|
882
|
-
|
|
883
|
-
try {
|
|
884
|
-
const findResult = manager.checkPermission("find", {}, "reviewer");
|
|
885
|
-
expect(findResult.state).toBe("allow");
|
|
886
|
-
expect(findResult.source).toBe("tool");
|
|
887
|
-
|
|
888
|
-
// In flat format any surface key works, including extension tools
|
|
889
|
-
const taskResult = manager.checkPermission("task", {}, "reviewer");
|
|
890
|
-
expect(taskResult.state).toBe("allow");
|
|
891
|
-
expect(taskResult.source).toBe("tool");
|
|
892
|
-
|
|
893
|
-
// mcp: allow catches all MCP targets
|
|
894
|
-
const mcpResult = manager.checkPermission(
|
|
895
|
-
"mcp",
|
|
896
|
-
{ tool: "exa:web_search_exa" },
|
|
897
|
-
"reviewer",
|
|
898
|
-
);
|
|
899
|
-
expect(mcpResult.state).toBe("allow");
|
|
900
|
-
} finally {
|
|
901
|
-
cleanup();
|
|
902
|
-
}
|
|
903
|
-
});
|
|
904
|
-
|
|
905
|
-
test("task uses exact-name tool permissions like any registered extension tool", () => {
|
|
906
|
-
const { manager, cleanup } = createManager({
|
|
907
|
-
permission: { "*": "deny", task: "allow" },
|
|
908
|
-
});
|
|
909
|
-
|
|
910
|
-
try {
|
|
911
|
-
const taskResult = manager.checkPermission("task", {});
|
|
912
|
-
expect(taskResult.state).toBe("allow");
|
|
913
|
-
expect(taskResult.source).toBe("tool");
|
|
914
|
-
} finally {
|
|
915
|
-
cleanup();
|
|
916
|
-
}
|
|
917
|
-
});
|
|
918
|
-
|
|
919
|
-
test("Tool registry resolves event tool names from string and object payloads", () => {
|
|
920
|
-
expect(getToolNameFromValue(" read ")).toBe("read");
|
|
921
|
-
expect(getToolNameFromValue({ toolName: "write" })).toBe("write");
|
|
922
|
-
expect(getToolNameFromValue({ name: "find" })).toBe("find");
|
|
923
|
-
expect(getToolNameFromValue({ tool: "grep" })).toBe("grep");
|
|
924
|
-
expect(getToolNameFromValue({})).toBe(null);
|
|
925
|
-
});
|
|
926
|
-
|
|
927
|
-
test("Tool registry blocks unregistered tools and handles aliases", () => {
|
|
928
|
-
const registeredTools = [
|
|
929
|
-
{ toolName: "mcp" },
|
|
930
|
-
{ toolName: "read" },
|
|
931
|
-
{ toolName: "bash" },
|
|
932
|
-
];
|
|
933
|
-
|
|
934
|
-
const unknownCheck = checkRequestedToolRegistration(
|
|
935
|
-
"third_party_tool",
|
|
936
|
-
registeredTools,
|
|
937
|
-
);
|
|
938
|
-
expect(unknownCheck.status).toBe("unregistered");
|
|
939
|
-
if (unknownCheck.status === "unregistered") {
|
|
940
|
-
expect(unknownCheck.availableToolNames).toEqual(["bash", "mcp", "read"]);
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
const aliasCheck = checkRequestedToolRegistration(
|
|
944
|
-
"legacy_read",
|
|
945
|
-
registeredTools,
|
|
946
|
-
{ legacy_read: "read" },
|
|
947
|
-
);
|
|
948
|
-
expect(aliasCheck.status).toBe("registered");
|
|
949
|
-
|
|
950
|
-
const missingNameCheck = checkRequestedToolRegistration(
|
|
951
|
-
" ",
|
|
952
|
-
registeredTools,
|
|
953
|
-
);
|
|
954
|
-
expect(missingNameCheck.status).toBe("missing-tool-name");
|
|
955
|
-
});
|
|
956
|
-
|
|
957
|
-
test("getToolPermission returns tool-level policy for canonical and extension tools", () => {
|
|
958
|
-
const { manager, cleanup } = createManager(
|
|
959
|
-
{
|
|
960
|
-
permission: { "*": "ask" },
|
|
961
|
-
},
|
|
962
|
-
{
|
|
963
|
-
reviewer: `---
|
|
964
|
-
name: reviewer
|
|
965
|
-
permission:
|
|
966
|
-
bash: deny
|
|
967
|
-
read: deny
|
|
968
|
-
task: allow
|
|
969
|
-
---
|
|
970
|
-
`,
|
|
971
|
-
},
|
|
972
|
-
);
|
|
973
|
-
|
|
974
|
-
try {
|
|
975
|
-
const bashPermission = manager.getToolPermission("bash", "reviewer");
|
|
976
|
-
expect(bashPermission).toBe("deny");
|
|
977
|
-
|
|
978
|
-
const taskPermission = manager.getToolPermission("task", "reviewer");
|
|
979
|
-
expect(taskPermission).toBe("allow");
|
|
980
|
-
|
|
981
|
-
const readPermission = manager.getToolPermission("read", "reviewer");
|
|
982
|
-
expect(readPermission).toBe("deny");
|
|
983
|
-
|
|
984
|
-
const defaultBashPermission = manager.getToolPermission("bash");
|
|
985
|
-
expect(defaultBashPermission).toBe("ask");
|
|
986
|
-
|
|
987
|
-
const { manager: manager2, cleanup: cleanup2 } = createManager({
|
|
988
|
-
permission: { "*": "deny", bash: "allow" },
|
|
989
|
-
});
|
|
990
|
-
|
|
991
|
-
try {
|
|
992
|
-
const globalBashPermission = manager2.getToolPermission("bash");
|
|
993
|
-
expect(globalBashPermission).toBe("allow");
|
|
994
|
-
} finally {
|
|
995
|
-
cleanup2();
|
|
996
|
-
}
|
|
997
|
-
} finally {
|
|
998
|
-
cleanup();
|
|
999
|
-
}
|
|
1000
|
-
});
|
|
1001
|
-
|
|
1002
|
-
test("getToolPermission supports arbitrary extension tool names", () => {
|
|
1003
|
-
const { manager, cleanup } = createManager({
|
|
1004
|
-
permission: { "*": "deny", third_party_tool: "allow" },
|
|
1005
|
-
});
|
|
1006
|
-
|
|
1007
|
-
try {
|
|
1008
|
-
const explicitPermission = manager.getToolPermission("third_party_tool");
|
|
1009
|
-
expect(explicitPermission).toBe("allow");
|
|
1010
|
-
|
|
1011
|
-
const fallbackPermission = manager.getToolPermission(
|
|
1012
|
-
"missing_extension_tool",
|
|
1013
|
-
);
|
|
1014
|
-
expect(fallbackPermission).toBe("deny");
|
|
1015
|
-
} finally {
|
|
1016
|
-
cleanup();
|
|
1017
|
-
}
|
|
1018
|
-
});
|
|
1019
|
-
|
|
1020
|
-
test("Yolo mode bypasses delegated ask routing when no parent forwarding target is available", () => {
|
|
1021
|
-
const targetSessionId = resolvePermissionForwardingTargetSessionId({
|
|
1022
|
-
hasUI: false,
|
|
1023
|
-
isSubagent: true,
|
|
1024
|
-
currentSessionId: "child-session",
|
|
1025
|
-
env: {},
|
|
1026
|
-
});
|
|
1027
|
-
|
|
1028
|
-
expect(targetSessionId).toBe(null);
|
|
1029
|
-
expect(
|
|
1030
|
-
canResolveAskPermissionRequest({
|
|
1031
|
-
config: { ...DEFAULT_EXTENSION_CONFIG, yoloMode: true },
|
|
1032
|
-
hasUI: false,
|
|
1033
|
-
isSubagent: true,
|
|
1034
|
-
}),
|
|
1035
|
-
).toBe(true);
|
|
1036
|
-
expect(
|
|
1037
|
-
shouldAutoApprovePermissionState("ask", {
|
|
1038
|
-
...DEFAULT_EXTENSION_CONFIG,
|
|
1039
|
-
yoloMode: true,
|
|
1040
|
-
}),
|
|
1041
|
-
).toBe(true);
|
|
1042
|
-
});
|
|
1043
|
-
|
|
1044
|
-
test("Permission forwarding resolves the parent interactive session from subagent runtime env", () => {
|
|
1045
|
-
const targetSessionId = resolvePermissionForwardingTargetSessionId({
|
|
1046
|
-
hasUI: false,
|
|
1047
|
-
isSubagent: true,
|
|
1048
|
-
currentSessionId: "child-session",
|
|
1049
|
-
env: {
|
|
1050
|
-
PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-session",
|
|
1051
|
-
},
|
|
1052
|
-
});
|
|
1053
|
-
|
|
1054
|
-
expect(targetSessionId).toBe("parent-session");
|
|
1055
|
-
});
|
|
1056
|
-
|
|
1057
|
-
test("Permission forwarding does not guess a target session when subagent runtime env is missing", () => {
|
|
1058
|
-
const targetSessionId = resolvePermissionForwardingTargetSessionId({
|
|
1059
|
-
hasUI: false,
|
|
1060
|
-
isSubagent: true,
|
|
1061
|
-
currentSessionId: "child-session",
|
|
1062
|
-
env: {},
|
|
1063
|
-
});
|
|
1064
|
-
|
|
1065
|
-
expect(targetSessionId).toBe(null);
|
|
1066
|
-
});
|
|
1067
|
-
|
|
1068
|
-
test("Permission forwarding uses session-scoped directories per interactive session", () => {
|
|
1069
|
-
const forwardingRoot = join(tmpdir(), "pi-permission-system-forwarding-root");
|
|
1070
|
-
const sessionA = createPermissionForwardingLocation(
|
|
1071
|
-
forwardingRoot,
|
|
1072
|
-
"session-a",
|
|
1073
|
-
);
|
|
1074
|
-
const sessionB = createPermissionForwardingLocation(
|
|
1075
|
-
forwardingRoot,
|
|
1076
|
-
"session-b",
|
|
1077
|
-
);
|
|
1078
|
-
|
|
1079
|
-
expect(sessionA.sessionRootDir).not.toBe(sessionB.sessionRootDir);
|
|
1080
|
-
expect(sessionA.requestsDir).not.toBe(sessionB.requestsDir);
|
|
1081
|
-
expect(sessionA.responsesDir).not.toBe(sessionB.responsesDir);
|
|
1082
|
-
});
|
|
1083
|
-
|
|
1084
|
-
test("Permission forwarding request routing only matches the intended UI session", () => {
|
|
1085
|
-
expect(
|
|
1086
|
-
isForwardedPermissionRequestForSession(
|
|
1087
|
-
{ targetSessionId: "session-a" },
|
|
1088
|
-
"session-a",
|
|
1089
|
-
),
|
|
1090
|
-
).toBe(true);
|
|
1091
|
-
expect(
|
|
1092
|
-
isForwardedPermissionRequestForSession(
|
|
1093
|
-
{ targetSessionId: "session-a" },
|
|
1094
|
-
"session-b",
|
|
1095
|
-
),
|
|
1096
|
-
).toBe(false);
|
|
1097
|
-
});
|
|
1098
|
-
|
|
1099
|
-
test("Permission forwarding rejects unresolved sentinel session ids", () => {
|
|
1100
|
-
const targetSessionId = resolvePermissionForwardingTargetSessionId({
|
|
1101
|
-
hasUI: true,
|
|
1102
|
-
isSubagent: false,
|
|
1103
|
-
currentSessionId: "unknown",
|
|
1104
|
-
});
|
|
1105
|
-
|
|
1106
|
-
expect(targetSessionId).toBe(null);
|
|
1107
|
-
});
|
|
1108
|
-
|
|
1109
|
-
// ---------------------------------------------------------------------------
|
|
1110
|
-
// Project-level and per-agent config scope tests
|
|
1111
|
-
// ---------------------------------------------------------------------------
|
|
1112
|
-
|
|
1113
|
-
type CreateManagerWithProjectOptions = CreateManagerOptions & {
|
|
1114
|
-
projectConfig?: ScopeConfig;
|
|
1115
|
-
projectAgentFiles?: Record<string, string>;
|
|
1116
|
-
};
|
|
1117
|
-
|
|
1118
|
-
function createManagerWithProject(
|
|
1119
|
-
config: ScopeConfig,
|
|
1120
|
-
agentFiles: Record<string, string> = {},
|
|
1121
|
-
options: CreateManagerWithProjectOptions = {},
|
|
1122
|
-
) {
|
|
1123
|
-
const baseDir = mkdtempSync(
|
|
1124
|
-
join(tmpdir(), "pi-permission-system-proj-test-"),
|
|
1125
|
-
);
|
|
1126
|
-
const globalConfigPath = join(baseDir, "pi-permissions.jsonc");
|
|
1127
|
-
const agentsDir = join(baseDir, "agents");
|
|
1128
|
-
const projectRoot = join(baseDir, "project");
|
|
1129
|
-
const projectGlobalConfigPath = join(projectRoot, "pi-permissions.jsonc");
|
|
1130
|
-
const projectAgentsDir = join(projectRoot, "agents");
|
|
1131
|
-
|
|
1132
|
-
mkdirSync(agentsDir, { recursive: true });
|
|
1133
|
-
mkdirSync(projectAgentsDir, { recursive: true });
|
|
1134
|
-
|
|
1135
|
-
writeFileSync(
|
|
1136
|
-
globalConfigPath,
|
|
1137
|
-
`${JSON.stringify(config, null, 2)}\n`,
|
|
1138
|
-
"utf8",
|
|
1139
|
-
);
|
|
1140
|
-
if (options.projectConfig) {
|
|
1141
|
-
writeFileSync(
|
|
1142
|
-
projectGlobalConfigPath,
|
|
1143
|
-
`${JSON.stringify(options.projectConfig, null, 2)}\n`,
|
|
1144
|
-
"utf8",
|
|
1145
|
-
);
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
for (const [name, content] of Object.entries(agentFiles)) {
|
|
1149
|
-
writeFileSync(join(agentsDir, `${name}.md`), content, "utf8");
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
for (const [name, content] of Object.entries(
|
|
1153
|
-
options.projectAgentFiles ?? {},
|
|
1154
|
-
)) {
|
|
1155
|
-
writeFileSync(join(projectAgentsDir, `${name}.md`), content, "utf8");
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
const manager = new PermissionManager({
|
|
1159
|
-
globalConfigPath,
|
|
1160
|
-
agentsDir,
|
|
1161
|
-
projectGlobalConfigPath,
|
|
1162
|
-
projectAgentsDir,
|
|
1163
|
-
mcpServerNames: options.mcpServerNames,
|
|
1164
|
-
});
|
|
1165
|
-
|
|
1166
|
-
return {
|
|
1167
|
-
manager,
|
|
1168
|
-
cleanup: (): void => {
|
|
1169
|
-
rmSync(baseDir, { recursive: true, force: true });
|
|
1170
|
-
},
|
|
1171
|
-
};
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
test("Project-level config overrides base bash patterns", () => {
|
|
1175
|
-
const { manager, cleanup } = createManagerWithProject(
|
|
1176
|
-
{
|
|
1177
|
-
permission: {
|
|
1178
|
-
"*": "allow",
|
|
1179
|
-
bash: { "*": "ask", "rm -rf *": "deny" },
|
|
1180
|
-
},
|
|
1181
|
-
},
|
|
1182
|
-
{},
|
|
1183
|
-
{
|
|
1184
|
-
projectConfig: {
|
|
1185
|
-
permission: { bash: { "rm -rf build": "allow" } },
|
|
1186
|
-
},
|
|
1187
|
-
},
|
|
1188
|
-
);
|
|
1189
|
-
|
|
1190
|
-
try {
|
|
1191
|
-
const allowed = manager.checkPermission("bash", {
|
|
1192
|
-
command: "rm -rf build",
|
|
1193
|
-
});
|
|
1194
|
-
expect(allowed.state).toBe("allow");
|
|
1195
|
-
expect(allowed.matchedPattern).toBe("rm -rf build");
|
|
1196
|
-
|
|
1197
|
-
const denied = manager.checkPermission("bash", {
|
|
1198
|
-
command: "rm -rf node_modules",
|
|
1199
|
-
});
|
|
1200
|
-
expect(denied.state).toBe("deny");
|
|
1201
|
-
expect(denied.matchedPattern).toBe("rm -rf *");
|
|
1202
|
-
} finally {
|
|
1203
|
-
cleanup();
|
|
1204
|
-
}
|
|
1205
|
-
});
|
|
1206
|
-
|
|
1207
|
-
test("System-agent config overrides project-level bash patterns", () => {
|
|
1208
|
-
const { manager, cleanup } = createManagerWithProject(
|
|
1209
|
-
{
|
|
1210
|
-
permission: { "*": "allow", bash: "ask" },
|
|
1211
|
-
},
|
|
1212
|
-
{
|
|
1213
|
-
reviewer: `---
|
|
1214
|
-
name: reviewer
|
|
1215
|
-
permission:
|
|
1216
|
-
bash:
|
|
1217
|
-
"git log *": allow
|
|
1218
|
-
---
|
|
1219
|
-
`,
|
|
1220
|
-
},
|
|
1221
|
-
{
|
|
1222
|
-
projectConfig: {
|
|
1223
|
-
permission: { bash: { "git *": "deny" } },
|
|
1224
|
-
},
|
|
1225
|
-
},
|
|
1226
|
-
);
|
|
1227
|
-
|
|
1228
|
-
try {
|
|
1229
|
-
const allowed = manager.checkPermission(
|
|
1230
|
-
"bash",
|
|
1231
|
-
{ command: "git log --oneline" },
|
|
1232
|
-
"reviewer",
|
|
1233
|
-
);
|
|
1234
|
-
expect(allowed.state).toBe("allow");
|
|
1235
|
-
expect(allowed.matchedPattern).toBe("git log *");
|
|
1236
|
-
|
|
1237
|
-
const denied = manager.checkPermission(
|
|
1238
|
-
"bash",
|
|
1239
|
-
{ command: "git status" },
|
|
1240
|
-
"reviewer",
|
|
1241
|
-
);
|
|
1242
|
-
expect(denied.state).toBe("deny");
|
|
1243
|
-
expect(denied.matchedPattern).toBe("git *");
|
|
1244
|
-
} finally {
|
|
1245
|
-
cleanup();
|
|
1246
|
-
}
|
|
1247
|
-
});
|
|
1248
|
-
|
|
1249
|
-
test("Project-agent config overrides system-agent tool rules", () => {
|
|
1250
|
-
const { manager, cleanup } = createManagerWithProject(
|
|
1251
|
-
{
|
|
1252
|
-
permission: { "*": "ask" },
|
|
1253
|
-
},
|
|
1254
|
-
{
|
|
1255
|
-
reviewer: `---
|
|
1256
|
-
name: reviewer
|
|
1257
|
-
permission:
|
|
1258
|
-
read: deny
|
|
1259
|
-
---
|
|
1260
|
-
`,
|
|
1261
|
-
},
|
|
1262
|
-
{
|
|
1263
|
-
projectAgentFiles: {
|
|
1264
|
-
reviewer: `---
|
|
1265
|
-
name: reviewer
|
|
1266
|
-
permission:
|
|
1267
|
-
read: allow
|
|
1268
|
-
---
|
|
1269
|
-
`,
|
|
1270
|
-
},
|
|
1271
|
-
},
|
|
1272
|
-
);
|
|
1273
|
-
|
|
1274
|
-
try {
|
|
1275
|
-
const result = manager.checkPermission("read", {}, "reviewer");
|
|
1276
|
-
expect(result.state).toBe("allow");
|
|
1277
|
-
expect(result.source).toBe("tool");
|
|
1278
|
-
} finally {
|
|
1279
|
-
cleanup();
|
|
1280
|
-
}
|
|
1281
|
-
});
|
|
1282
|
-
|
|
1283
|
-
test("Full precedence chain base < project < system-agent < project-agent for universal default", () => {
|
|
1284
|
-
const { manager, cleanup } = createManagerWithProject(
|
|
1285
|
-
{
|
|
1286
|
-
permission: { "*": "deny" },
|
|
1287
|
-
},
|
|
1288
|
-
{
|
|
1289
|
-
reviewer: `---
|
|
1290
|
-
name: reviewer
|
|
1291
|
-
permission:
|
|
1292
|
-
"*": ask
|
|
1293
|
-
---
|
|
1294
|
-
`,
|
|
1295
|
-
},
|
|
1296
|
-
{
|
|
1297
|
-
projectConfig: {
|
|
1298
|
-
permission: { "*": "allow" },
|
|
1299
|
-
},
|
|
1300
|
-
projectAgentFiles: {
|
|
1301
|
-
reviewer: `---
|
|
1302
|
-
name: reviewer
|
|
1303
|
-
permission:
|
|
1304
|
-
"*": deny
|
|
1305
|
-
---
|
|
1306
|
-
`,
|
|
1307
|
-
},
|
|
1308
|
-
},
|
|
1309
|
-
);
|
|
1310
|
-
|
|
1311
|
-
try {
|
|
1312
|
-
const reviewerResult = manager.checkPermission(
|
|
1313
|
-
"custom_extension_tool",
|
|
1314
|
-
{},
|
|
1315
|
-
"reviewer",
|
|
1316
|
-
);
|
|
1317
|
-
expect(reviewerResult.state).toBe("deny");
|
|
1318
|
-
expect(reviewerResult.source).toBe("default");
|
|
1319
|
-
|
|
1320
|
-
const globalResult = manager.checkPermission("custom_extension_tool", {});
|
|
1321
|
-
expect(globalResult.state).toBe("allow");
|
|
1322
|
-
expect(globalResult.source).toBe("default");
|
|
1323
|
-
} finally {
|
|
1324
|
-
cleanup();
|
|
1325
|
-
}
|
|
1326
|
-
});
|
|
1327
|
-
|
|
1328
|
-
test("Project-agent applies even without a matching system-agent file", () => {
|
|
1329
|
-
const { manager, cleanup } = createManagerWithProject(
|
|
1330
|
-
{
|
|
1331
|
-
permission: { "*": "allow" },
|
|
1332
|
-
},
|
|
1333
|
-
{},
|
|
1334
|
-
{
|
|
1335
|
-
projectAgentFiles: {
|
|
1336
|
-
reviewer: `---
|
|
1337
|
-
name: reviewer
|
|
1338
|
-
permission:
|
|
1339
|
-
read: deny
|
|
1340
|
-
---
|
|
1341
|
-
`,
|
|
1342
|
-
},
|
|
1343
|
-
},
|
|
1344
|
-
);
|
|
1345
|
-
|
|
1346
|
-
try {
|
|
1347
|
-
const agentResult = manager.checkPermission("read", {}, "reviewer");
|
|
1348
|
-
expect(agentResult.state).toBe("deny");
|
|
1349
|
-
expect(agentResult.source).toBe("tool");
|
|
1350
|
-
|
|
1351
|
-
const globalResult = manager.checkPermission("read", {});
|
|
1352
|
-
expect(globalResult.state).toBe("allow");
|
|
1353
|
-
expect(globalResult.source).toBe("tool");
|
|
1354
|
-
} finally {
|
|
1355
|
-
cleanup();
|
|
1356
|
-
}
|
|
1357
|
-
});
|
|
1358
|
-
|
|
1359
|
-
// ---------------------------------------------------------------------------
|
|
1360
|
-
// PI_CODING_AGENT_DIR support
|
|
1361
|
-
// ---------------------------------------------------------------------------
|
|
1362
|
-
|
|
1363
|
-
test("PermissionManager reads config from PI_CODING_AGENT_DIR when set", () => {
|
|
1364
|
-
const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-envdir-"));
|
|
1365
|
-
const agentsDir = join(baseDir, "agents");
|
|
1366
|
-
const newConfigPath = getGlobalConfigPath(baseDir);
|
|
1367
|
-
mkdirSync(agentsDir, { recursive: true });
|
|
1368
|
-
mkdirSync(dirname(newConfigPath), { recursive: true });
|
|
1369
|
-
|
|
1370
|
-
const config: ScopeConfig = {
|
|
1371
|
-
permission: { "*": "deny", read: "allow" },
|
|
1372
|
-
};
|
|
1373
|
-
writeFileSync(newConfigPath, JSON.stringify(config), "utf8");
|
|
1374
|
-
|
|
1375
|
-
const original = process.env.PI_CODING_AGENT_DIR;
|
|
1376
|
-
process.env.PI_CODING_AGENT_DIR = baseDir;
|
|
1377
|
-
try {
|
|
1378
|
-
const manager = new PermissionManager();
|
|
1379
|
-
const result = manager.checkPermission("read", {});
|
|
1380
|
-
expect(result.state).toBe("allow");
|
|
1381
|
-
|
|
1382
|
-
const result2 = manager.checkPermission("write", {});
|
|
1383
|
-
expect(result2.state).toBe("deny");
|
|
1384
|
-
} finally {
|
|
1385
|
-
if (original !== undefined) {
|
|
1386
|
-
process.env.PI_CODING_AGENT_DIR = original;
|
|
1387
|
-
} else {
|
|
1388
|
-
delete process.env.PI_CODING_AGENT_DIR;
|
|
1389
|
-
}
|
|
1390
|
-
rmSync(baseDir, { recursive: true, force: true });
|
|
1391
|
-
}
|
|
1392
|
-
});
|
|
1393
|
-
|
|
1394
|
-
// ---------------------------------------------------------------------------
|
|
1395
|
-
// Skill prompt sanitization - multi-block regression tests
|
|
1396
|
-
// ---------------------------------------------------------------------------
|
|
1397
|
-
|
|
1398
|
-
test("parseAllSkillPromptSections finds every available_skills block", () => {
|
|
1399
|
-
const prompt = [
|
|
1400
|
-
"Some preamble",
|
|
1401
|
-
"<available_skills>",
|
|
1402
|
-
" <skill>",
|
|
1403
|
-
" <name>skill-one</name>",
|
|
1404
|
-
" <description>First skill</description>",
|
|
1405
|
-
" <location>/path/to/one</location>",
|
|
1406
|
-
" </skill>",
|
|
1407
|
-
"</available_skills>",
|
|
1408
|
-
"Some content between",
|
|
1409
|
-
"<available_skills>",
|
|
1410
|
-
" <skill>",
|
|
1411
|
-
" <name>skill-two</name>",
|
|
1412
|
-
" <description>Second skill</description>",
|
|
1413
|
-
" <location>/path/to/two</location>",
|
|
1414
|
-
" </skill>",
|
|
1415
|
-
"</available_skills>",
|
|
1416
|
-
"Footer",
|
|
1417
|
-
].join("\n");
|
|
1418
|
-
|
|
1419
|
-
const sections = parseAllSkillPromptSections(prompt);
|
|
1420
|
-
|
|
1421
|
-
expect(sections.length).toBe(2);
|
|
1422
|
-
expect(sections[0].entries[0]?.name).toBe("skill-one");
|
|
1423
|
-
expect(sections[1].entries[0]?.name).toBe("skill-two");
|
|
1424
|
-
});
|
|
1425
|
-
|
|
1426
|
-
test("REGRESSION: resolveSkillPromptEntries sanitizes every available_skills block", () => {
|
|
1427
|
-
const { manager, cleanup } = createManager({
|
|
1428
|
-
permission: {
|
|
1429
|
-
"*": "ask",
|
|
1430
|
-
skill: { "denied-skill": "deny" },
|
|
1431
|
-
},
|
|
1432
|
-
});
|
|
1433
|
-
|
|
1434
|
-
try {
|
|
1435
|
-
const prompt = [
|
|
1436
|
-
"System prompt start",
|
|
1437
|
-
"<available_skills>",
|
|
1438
|
-
" <skill>",
|
|
1439
|
-
" <name>visible-skill</name>",
|
|
1440
|
-
" <description>Allowed skill</description>",
|
|
1441
|
-
" <location>/skills/visible/index.ts</location>",
|
|
1442
|
-
" </skill>",
|
|
1443
|
-
" <skill>",
|
|
1444
|
-
" <name>denied-skill</name>",
|
|
1445
|
-
" <description>Denied in first block</description>",
|
|
1446
|
-
" <location>/skills/blocked/one.ts</location>",
|
|
1447
|
-
" </skill>",
|
|
1448
|
-
"</available_skills>",
|
|
1449
|
-
"Agent identity section",
|
|
1450
|
-
"<available_skills>",
|
|
1451
|
-
" <skill>",
|
|
1452
|
-
" <name>denied-skill</name>",
|
|
1453
|
-
" <description>Denied in second block</description>",
|
|
1454
|
-
" <location>/skills/blocked/two.ts</location>",
|
|
1455
|
-
" </skill>",
|
|
1456
|
-
"</available_skills>",
|
|
1457
|
-
"System prompt end",
|
|
1458
|
-
].join("\n");
|
|
1459
|
-
|
|
1460
|
-
const result = resolveSkillPromptEntries(prompt, manager, null, "/cwd");
|
|
1461
|
-
|
|
1462
|
-
expect(result.prompt).not.toContain("denied-skill");
|
|
1463
|
-
expect(result.prompt).toContain("visible-skill");
|
|
1464
|
-
expect((result.prompt.match(/<available_skills>/g) ?? []).length).toBe(1);
|
|
1465
|
-
expect(result.entries.map((entry) => entry.name)).toEqual([
|
|
1466
|
-
"visible-skill",
|
|
1467
|
-
]);
|
|
1468
|
-
} finally {
|
|
1469
|
-
cleanup();
|
|
1470
|
-
}
|
|
1471
|
-
});
|
|
1472
|
-
|
|
1473
|
-
test("REGRESSION: resolveSkillPromptEntries keeps only visible skills available for path matching", () => {
|
|
1474
|
-
const { manager, cleanup } = createManager({
|
|
1475
|
-
permission: {
|
|
1476
|
-
"*": "ask",
|
|
1477
|
-
skill: { "blocked-skill": "deny" },
|
|
1478
|
-
},
|
|
1479
|
-
});
|
|
1480
|
-
|
|
1481
|
-
try {
|
|
1482
|
-
const prompt = [
|
|
1483
|
-
"System prompt start",
|
|
1484
|
-
"<available_skills>",
|
|
1485
|
-
" <skill>",
|
|
1486
|
-
" <name>blocked-skill</name>",
|
|
1487
|
-
" <description>Blocked skill</description>",
|
|
1488
|
-
" <location>@./skills/blocked/entry.ts</location>",
|
|
1489
|
-
" </skill>",
|
|
1490
|
-
"</available_skills>",
|
|
1491
|
-
"Middle section",
|
|
1492
|
-
"<available_skills>",
|
|
1493
|
-
" <skill>",
|
|
1494
|
-
" <name>visible-skill</name>",
|
|
1495
|
-
" <description>Visible skill</description>",
|
|
1496
|
-
" <location>@./skills/visible/entry.ts</location>",
|
|
1497
|
-
" </skill>",
|
|
1498
|
-
"</available_skills>",
|
|
1499
|
-
"System prompt end",
|
|
1500
|
-
].join("\n");
|
|
1501
|
-
|
|
1502
|
-
const result = resolveSkillPromptEntries(prompt, manager, null, "/cwd");
|
|
1503
|
-
const visiblePath = resolve("/cwd", "./skills/visible/file.ts");
|
|
1504
|
-
const blockedPath = resolve("/cwd", "./skills/blocked/file.ts");
|
|
1505
|
-
const matchedVisibleSkill = findSkillPathMatch(
|
|
1506
|
-
process.platform === "win32" ? visiblePath.toLowerCase() : visiblePath,
|
|
1507
|
-
result.entries,
|
|
1508
|
-
);
|
|
1509
|
-
const matchedBlockedSkill = findSkillPathMatch(
|
|
1510
|
-
process.platform === "win32" ? blockedPath.toLowerCase() : blockedPath,
|
|
1511
|
-
result.entries,
|
|
1512
|
-
);
|
|
1513
|
-
|
|
1514
|
-
expect(matchedVisibleSkill?.name).toBe("visible-skill");
|
|
1515
|
-
expect(matchedBlockedSkill).toBe(null);
|
|
1516
|
-
} finally {
|
|
1517
|
-
cleanup();
|
|
1518
|
-
}
|
|
1519
|
-
});
|
|
1520
|
-
|
|
1521
|
-
// ---------------------------------------------------------------------------
|
|
1522
|
-
// external_directory special permission
|
|
1523
|
-
// ---------------------------------------------------------------------------
|
|
1524
|
-
|
|
1525
|
-
test("external_directory permission falls back to universal default when not explicitly configured", () => {
|
|
1526
|
-
// Empty permission: everything defaults to "ask" (least privilege).
|
|
1527
|
-
const { manager, cleanup } = createManager({ permission: {} });
|
|
1528
|
-
|
|
1529
|
-
try {
|
|
1530
|
-
const result = manager.checkPermission("external_directory", {});
|
|
1531
|
-
expect(result.state).toBe("ask");
|
|
1532
|
-
expect(result.source).toBe("special");
|
|
1533
|
-
expect(result.matchedPattern).toBe(undefined);
|
|
1534
|
-
} finally {
|
|
1535
|
-
cleanup();
|
|
1536
|
-
}
|
|
1537
|
-
});
|
|
1538
|
-
|
|
1539
|
-
test("external_directory permission respects explicit deny", () => {
|
|
1540
|
-
const { manager, cleanup } = createManager({
|
|
1541
|
-
permission: { "*": "allow", external_directory: "deny" },
|
|
1542
|
-
});
|
|
1543
|
-
|
|
1544
|
-
try {
|
|
1545
|
-
const result = manager.checkPermission("external_directory", {});
|
|
1546
|
-
expect(result.state).toBe("deny");
|
|
1547
|
-
expect(result.source).toBe("special");
|
|
1548
|
-
expect(result.matchedPattern).toBe("*");
|
|
1549
|
-
} finally {
|
|
1550
|
-
cleanup();
|
|
1551
|
-
}
|
|
1552
|
-
});
|
|
1553
|
-
|
|
1554
|
-
test("external_directory permission can be explicitly allowed", () => {
|
|
1555
|
-
const { manager, cleanup } = createManager({
|
|
1556
|
-
permission: { "*": "allow", external_directory: "allow" },
|
|
1557
|
-
});
|
|
1558
|
-
|
|
1559
|
-
try {
|
|
1560
|
-
const result = manager.checkPermission("external_directory", {});
|
|
1561
|
-
expect(result.state).toBe("allow");
|
|
1562
|
-
expect(result.source).toBe("special");
|
|
1563
|
-
expect(result.matchedPattern).toBe("*");
|
|
1564
|
-
} finally {
|
|
1565
|
-
cleanup();
|
|
1566
|
-
}
|
|
1567
|
-
});
|
|
1568
|
-
|
|
1569
|
-
test("external_directory permission respects per-agent override", () => {
|
|
1570
|
-
const { manager, cleanup } = createManager(
|
|
1571
|
-
{
|
|
1572
|
-
permission: { "*": "allow", external_directory: "deny" },
|
|
1573
|
-
},
|
|
1574
|
-
{
|
|
1575
|
-
trusted: `---
|
|
1576
|
-
name: trusted
|
|
1577
|
-
permission:
|
|
1578
|
-
external_directory: allow
|
|
1579
|
-
---
|
|
1580
|
-
`,
|
|
1581
|
-
},
|
|
1582
|
-
);
|
|
1583
|
-
|
|
1584
|
-
try {
|
|
1585
|
-
// Global policy denies external_directory
|
|
1586
|
-
const globalResult = manager.checkPermission("external_directory", {});
|
|
1587
|
-
expect(globalResult.state).toBe("deny");
|
|
1588
|
-
|
|
1589
|
-
// Trusted agent overrides to allow
|
|
1590
|
-
const agentResult = manager.checkPermission(
|
|
1591
|
-
"external_directory",
|
|
1592
|
-
{},
|
|
1593
|
-
"trusted",
|
|
1594
|
-
);
|
|
1595
|
-
expect(agentResult.state).toBe("allow");
|
|
1596
|
-
expect(agentResult.source).toBe("special");
|
|
1597
|
-
} finally {
|
|
1598
|
-
cleanup();
|
|
1599
|
-
}
|
|
1600
|
-
});
|
|
1601
|
-
|
|
1602
|
-
test("external_directory permission is not affected by unrelated surface keys", () => {
|
|
1603
|
-
// Flat format: unknown surface keys are just rules for that surface.
|
|
1604
|
-
// external_directory resolves from its own rule, not from unrelated keys.
|
|
1605
|
-
const { manager, cleanup } = createManager({
|
|
1606
|
-
permission: { "*": "allow", external_directory: "allow" },
|
|
1607
|
-
});
|
|
1608
|
-
|
|
1609
|
-
try {
|
|
1610
|
-
// external_directory still resolves from its own entry
|
|
1611
|
-
const extResult = manager.checkPermission("external_directory", {});
|
|
1612
|
-
expect(extResult.state).toBe("allow");
|
|
1613
|
-
expect(extResult.matchedPattern).toBe("*");
|
|
1614
|
-
} finally {
|
|
1615
|
-
cleanup();
|
|
1616
|
-
}
|
|
1617
|
-
});
|
|
1618
|
-
|
|
1619
|
-
test("skill pattern map in agent frontmatter overrides global skill policy", () => {
|
|
1620
|
-
const { manager, cleanup } = createManager(
|
|
1621
|
-
{
|
|
1622
|
-
permission: { "*": "deny", skill: "deny" },
|
|
1623
|
-
},
|
|
1624
|
-
{
|
|
1625
|
-
reviewer: `---
|
|
1626
|
-
name: reviewer
|
|
1627
|
-
permission:
|
|
1628
|
-
skill:
|
|
1629
|
-
"*": ask
|
|
1630
|
-
"pi-*": allow
|
|
1631
|
-
---
|
|
1632
|
-
`,
|
|
1633
|
-
},
|
|
1634
|
-
);
|
|
1635
|
-
|
|
1636
|
-
try {
|
|
1637
|
-
// Matches agent frontmatter pi-* pattern
|
|
1638
|
-
const allowed = manager.checkPermission(
|
|
1639
|
-
"skill",
|
|
1640
|
-
{ name: "pi-code-review" },
|
|
1641
|
-
"reviewer",
|
|
1642
|
-
);
|
|
1643
|
-
expect(allowed.state).toBe("allow");
|
|
1644
|
-
expect(allowed.matchedPattern).toBe("pi-*");
|
|
1645
|
-
expect(allowed.source).toBe("skill");
|
|
1646
|
-
|
|
1647
|
-
// Falls through to agent frontmatter catch-all
|
|
1648
|
-
const asked = manager.checkPermission(
|
|
1649
|
-
"skill",
|
|
1650
|
-
{ name: "other-skill" },
|
|
1651
|
-
"reviewer",
|
|
1652
|
-
);
|
|
1653
|
-
expect(asked.state).toBe("ask");
|
|
1654
|
-
expect(asked.matchedPattern).toBe("*");
|
|
1655
|
-
|
|
1656
|
-
// No agent override — global deny applies
|
|
1657
|
-
const denied = manager.checkPermission("skill", { name: "pi-code-review" });
|
|
1658
|
-
expect(denied.state).toBe("deny");
|
|
1659
|
-
expect(denied.source).toBe("skill");
|
|
1660
|
-
} finally {
|
|
1661
|
-
cleanup();
|
|
1662
|
-
}
|
|
1663
|
-
});
|
|
1664
|
-
|
|
1665
|
-
test("external_directory pattern map in agent frontmatter overrides global policy", () => {
|
|
1666
|
-
const { manager, cleanup } = createManager(
|
|
1667
|
-
{
|
|
1668
|
-
permission: { "*": "allow", external_directory: "deny" },
|
|
1669
|
-
},
|
|
1670
|
-
{
|
|
1671
|
-
trusted: `---
|
|
1672
|
-
name: trusted
|
|
1673
|
-
permission:
|
|
1674
|
-
external_directory:
|
|
1675
|
-
"*": deny
|
|
1676
|
-
"~/Downloads/*": allow
|
|
1677
|
-
---
|
|
1678
|
-
`,
|
|
1679
|
-
},
|
|
1680
|
-
);
|
|
1681
|
-
|
|
1682
|
-
try {
|
|
1683
|
-
// Matches agent frontmatter ~/Downloads/* pattern
|
|
1684
|
-
const allowed = manager.checkPermission(
|
|
1685
|
-
"external_directory",
|
|
1686
|
-
{ path: `${homedir()}/Downloads/file.txt` },
|
|
1687
|
-
"trusted",
|
|
1688
|
-
);
|
|
1689
|
-
expect(allowed.state).toBe("allow");
|
|
1690
|
-
expect(allowed.matchedPattern).toBe("~/Downloads/*");
|
|
1691
|
-
expect(allowed.source).toBe("special");
|
|
1692
|
-
|
|
1693
|
-
// Falls through to agent frontmatter catch-all deny
|
|
1694
|
-
const denied = manager.checkPermission(
|
|
1695
|
-
"external_directory",
|
|
1696
|
-
{ path: `${homedir()}/Documents/secret.txt` },
|
|
1697
|
-
"trusted",
|
|
1698
|
-
);
|
|
1699
|
-
expect(denied.state).toBe("deny");
|
|
1700
|
-
expect(denied.matchedPattern).toBe("*");
|
|
1701
|
-
|
|
1702
|
-
// No agent override — global deny applies
|
|
1703
|
-
const globalDenied = manager.checkPermission("external_directory", {});
|
|
1704
|
-
expect(globalDenied.state).toBe("deny");
|
|
1705
|
-
expect(globalDenied.source).toBe("special");
|
|
1706
|
-
} finally {
|
|
1707
|
-
cleanup();
|
|
1708
|
-
}
|
|
1709
|
-
});
|
|
1710
|
-
|
|
1711
|
-
test("project-agent frontmatter skill rules override global-agent frontmatter skill rules", () => {
|
|
1712
|
-
const { manager, cleanup } = createManagerWithProject(
|
|
1713
|
-
{
|
|
1714
|
-
permission: { "*": "deny" },
|
|
1715
|
-
},
|
|
1716
|
-
{
|
|
1717
|
-
analyst: `---
|
|
1718
|
-
name: analyst
|
|
1719
|
-
permission:
|
|
1720
|
-
skill:
|
|
1721
|
-
"*": ask
|
|
1722
|
-
---
|
|
1723
|
-
`,
|
|
1724
|
-
},
|
|
1725
|
-
{
|
|
1726
|
-
projectAgentFiles: {
|
|
1727
|
-
analyst: `---
|
|
1728
|
-
name: analyst
|
|
1729
|
-
permission:
|
|
1730
|
-
skill:
|
|
1731
|
-
"pi-*": allow
|
|
1732
|
-
"*": deny
|
|
1733
|
-
---
|
|
1734
|
-
`,
|
|
1735
|
-
},
|
|
1736
|
-
},
|
|
1737
|
-
);
|
|
1738
|
-
|
|
1739
|
-
try {
|
|
1740
|
-
// Project-agent pi-* wins over global-agent *: ask
|
|
1741
|
-
const allowed = manager.checkPermission(
|
|
1742
|
-
"skill",
|
|
1743
|
-
{ name: "pi-code-review" },
|
|
1744
|
-
"analyst",
|
|
1745
|
-
);
|
|
1746
|
-
expect(allowed.state).toBe("allow");
|
|
1747
|
-
expect(allowed.matchedPattern).toBe("pi-*");
|
|
1748
|
-
|
|
1749
|
-
// Project-agent *: deny wins over global-agent *: ask
|
|
1750
|
-
const denied = manager.checkPermission(
|
|
1751
|
-
"skill",
|
|
1752
|
-
{ name: "other-skill" },
|
|
1753
|
-
"analyst",
|
|
1754
|
-
);
|
|
1755
|
-
expect(denied.state).toBe("deny");
|
|
1756
|
-
expect(denied.matchedPattern).toBe("*");
|
|
1757
|
-
} finally {
|
|
1758
|
-
cleanup();
|
|
1759
|
-
}
|
|
1760
|
-
});
|
|
1761
|
-
|
|
1762
|
-
test("project-agent frontmatter external_directory rules override global-agent frontmatter rules", () => {
|
|
1763
|
-
const { manager, cleanup } = createManagerWithProject(
|
|
1764
|
-
{
|
|
1765
|
-
permission: { "*": "allow", external_directory: "deny" },
|
|
1766
|
-
},
|
|
1767
|
-
{
|
|
1768
|
-
analyst: `---
|
|
1769
|
-
name: analyst
|
|
1770
|
-
permission:
|
|
1771
|
-
external_directory: ask
|
|
1772
|
-
---
|
|
1773
|
-
`,
|
|
1774
|
-
},
|
|
1775
|
-
{
|
|
1776
|
-
projectAgentFiles: {
|
|
1777
|
-
analyst: `---
|
|
1778
|
-
name: analyst
|
|
1779
|
-
permission:
|
|
1780
|
-
external_directory: allow
|
|
1781
|
-
---
|
|
1782
|
-
`,
|
|
1783
|
-
},
|
|
1784
|
-
},
|
|
1785
|
-
);
|
|
1786
|
-
|
|
1787
|
-
try {
|
|
1788
|
-
// Project-agent allow wins over global-agent ask
|
|
1789
|
-
const result = manager.checkPermission("external_directory", {}, "analyst");
|
|
1790
|
-
expect(result.state).toBe("allow");
|
|
1791
|
-
expect(result.source).toBe("special");
|
|
1792
|
-
|
|
1793
|
-
// Without agent context, global config deny applies
|
|
1794
|
-
const globalResult = manager.checkPermission("external_directory", {});
|
|
1795
|
-
expect(globalResult.state).toBe("deny");
|
|
1796
|
-
} finally {
|
|
1797
|
-
cleanup();
|
|
1798
|
-
}
|
|
1799
|
-
});
|
|
1800
|
-
|
|
1801
|
-
test("tool_call blocks path-bearing tools outside cwd when external_directory is denied", async () => {
|
|
1802
|
-
const rootDir = mkdtempSync(join(tmpdir(), "pi-permission-system-boundary-"));
|
|
1803
|
-
const cwd = join(rootDir, "repo");
|
|
1804
|
-
const siblingPath = join(rootDir, "repo-sibling", "secret.txt");
|
|
1805
|
-
mkdirSync(join(rootDir, "repo-sibling"), { recursive: true });
|
|
1806
|
-
|
|
1807
|
-
const harness = createToolCallHarness(
|
|
1808
|
-
{
|
|
1809
|
-
permission: { "*": "allow", external_directory: "deny" },
|
|
1810
|
-
},
|
|
1811
|
-
["read"],
|
|
1812
|
-
{ cwd },
|
|
1813
|
-
);
|
|
1814
|
-
|
|
1815
|
-
try {
|
|
1816
|
-
const result = await runToolCall(harness, {
|
|
1817
|
-
toolName: "read",
|
|
1818
|
-
toolCallId: "external-deny",
|
|
1819
|
-
input: { path: siblingPath },
|
|
1820
|
-
});
|
|
1821
|
-
|
|
1822
|
-
expect(result.block).toBe(true);
|
|
1823
|
-
const reason = String(result.reason);
|
|
1824
|
-
expect(reason).toContain("is not permitted to run tool 'read'");
|
|
1825
|
-
expect(reason).toContain("repo-sibling");
|
|
1826
|
-
expect(reason).toContain("[pi-permission-system]");
|
|
1827
|
-
expect(reason).not.toContain("Hard stop");
|
|
1828
|
-
} finally {
|
|
1829
|
-
await harness.cleanup();
|
|
1830
|
-
rmSync(rootDir, { recursive: true, force: true });
|
|
1831
|
-
}
|
|
1832
|
-
});
|
|
1833
|
-
|
|
1834
|
-
test("tool_call allows path-bearing tools inside cwd without external_directory prompt", async () => {
|
|
1835
|
-
const harness = createToolCallHarness(
|
|
1836
|
-
{
|
|
1837
|
-
permission: { "*": "allow", external_directory: "deny" },
|
|
1838
|
-
},
|
|
1839
|
-
["read"],
|
|
1840
|
-
);
|
|
1841
|
-
|
|
1842
|
-
try {
|
|
1843
|
-
const result = await runToolCall(harness, {
|
|
1844
|
-
toolName: "read",
|
|
1845
|
-
toolCallId: "internal-allow",
|
|
1846
|
-
input: { path: join(harness.cwd, "src", "index.ts") },
|
|
1847
|
-
});
|
|
1848
|
-
|
|
1849
|
-
expect(result).toEqual({});
|
|
1850
|
-
expect(harness.prompts).toEqual([]);
|
|
1851
|
-
} finally {
|
|
1852
|
-
await harness.cleanup();
|
|
1853
|
-
}
|
|
1854
|
-
});
|
|
1855
|
-
|
|
1856
|
-
test("tool_call blocks external_directory ask when no confirmation channel is available", async () => {
|
|
1857
|
-
const harness = createToolCallHarness(
|
|
1858
|
-
{
|
|
1859
|
-
permission: { "*": "allow", external_directory: "ask" },
|
|
1860
|
-
},
|
|
1861
|
-
["write"],
|
|
1862
|
-
);
|
|
1863
|
-
|
|
1864
|
-
try {
|
|
1865
|
-
const result = await runToolCall(harness, {
|
|
1866
|
-
toolName: "write",
|
|
1867
|
-
toolCallId: "external-ask-no-ui",
|
|
1868
|
-
input: {
|
|
1869
|
-
path: join(harness.cwd, "..", "outside.txt"),
|
|
1870
|
-
content: "blocked",
|
|
1871
|
-
},
|
|
1872
|
-
});
|
|
1873
|
-
|
|
1874
|
-
expect(result.block).toBe(true);
|
|
1875
|
-
expect(String(result.reason)).toMatch(
|
|
1876
|
-
/requires approval, but no interactive UI is available/i,
|
|
1877
|
-
);
|
|
1878
|
-
} finally {
|
|
1879
|
-
await harness.cleanup();
|
|
1880
|
-
}
|
|
1881
|
-
});
|
|
1882
|
-
|
|
1883
|
-
test("tool_call prompts for external_directory and then falls through to normal tool policy", async () => {
|
|
1884
|
-
const harness = createToolCallHarness(
|
|
1885
|
-
{
|
|
1886
|
-
permission: { "*": "allow", external_directory: "ask" },
|
|
1887
|
-
},
|
|
1888
|
-
["grep"],
|
|
1889
|
-
);
|
|
1890
|
-
|
|
1891
|
-
try {
|
|
1892
|
-
const externalPath = join(harness.cwd, "..", "external-search-root");
|
|
1893
|
-
const result = await runToolCall(
|
|
1894
|
-
harness,
|
|
1895
|
-
{
|
|
1896
|
-
toolName: "grep",
|
|
1897
|
-
toolCallId: "external-ask-approved",
|
|
1898
|
-
input: { pattern: "needle", path: externalPath },
|
|
1899
|
-
},
|
|
1900
|
-
{ hasUI: true, selectResponse: "Yes" },
|
|
1901
|
-
);
|
|
1902
|
-
|
|
1903
|
-
expect(result).toEqual({});
|
|
1904
|
-
expect(harness.prompts.length).toBe(1);
|
|
1905
|
-
expect(harness.prompts[0]).toMatch(/external directory access/i);
|
|
1906
|
-
expect(harness.prompts[0]).toMatch(/grep/);
|
|
1907
|
-
expect(harness.prompts[0]).toMatch(/external-search-root/);
|
|
1908
|
-
} finally {
|
|
1909
|
-
await harness.cleanup();
|
|
1910
|
-
}
|
|
1911
|
-
});
|
|
1912
|
-
|
|
1913
|
-
test("tool_call skips external_directory checks for optional path tools without a path", async () => {
|
|
1914
|
-
const harness = createToolCallHarness(
|
|
1915
|
-
{
|
|
1916
|
-
permission: { "*": "allow", external_directory: "deny" },
|
|
1917
|
-
},
|
|
1918
|
-
["find"],
|
|
1919
|
-
);
|
|
1920
|
-
|
|
1921
|
-
try {
|
|
1922
|
-
const result = await runToolCall(harness, {
|
|
1923
|
-
toolName: "find",
|
|
1924
|
-
toolCallId: "find-default-cwd",
|
|
1925
|
-
input: { pattern: "*.ts" },
|
|
1926
|
-
});
|
|
1927
|
-
|
|
1928
|
-
expect(result).toEqual({});
|
|
1929
|
-
expect(harness.prompts).toEqual([]);
|
|
1930
|
-
} finally {
|
|
1931
|
-
await harness.cleanup();
|
|
1932
|
-
}
|
|
1933
|
-
});
|
|
1934
|
-
|
|
1935
|
-
// --- bash external_directory integration tests (#39) ---
|
|
1936
|
-
|
|
1937
|
-
test("tool_call blocks bash command with external path when external_directory is denied", async () => {
|
|
1938
|
-
const harness = createToolCallHarness(
|
|
1939
|
-
{
|
|
1940
|
-
permission: { "*": "allow", external_directory: "deny" },
|
|
1941
|
-
},
|
|
1942
|
-
["bash"],
|
|
1943
|
-
);
|
|
1944
|
-
|
|
1945
|
-
try {
|
|
1946
|
-
const result = await runToolCall(harness, {
|
|
1947
|
-
toolName: "bash",
|
|
1948
|
-
toolCallId: "bash-external-deny",
|
|
1949
|
-
input: { command: "cat /etc/hosts" },
|
|
1950
|
-
});
|
|
1951
|
-
|
|
1952
|
-
expect(result.block).toBe(true);
|
|
1953
|
-
const reason = String(result.reason);
|
|
1954
|
-
expect(reason).toContain(
|
|
1955
|
-
"is not permitted to run bash command 'cat /etc/hosts'",
|
|
1956
|
-
);
|
|
1957
|
-
expect(reason).toContain("/etc/hosts");
|
|
1958
|
-
expect(reason).toContain("[pi-permission-system]");
|
|
1959
|
-
expect(reason).not.toContain("Hard stop");
|
|
1960
|
-
} finally {
|
|
1961
|
-
await harness.cleanup();
|
|
1962
|
-
}
|
|
1963
|
-
});
|
|
1964
|
-
|
|
1965
|
-
test("tool_call allows bash command with only internal paths when external_directory is denied", async () => {
|
|
1966
|
-
const harness = createToolCallHarness(
|
|
1967
|
-
{
|
|
1968
|
-
permission: { "*": "allow", external_directory: "deny" },
|
|
1969
|
-
},
|
|
1970
|
-
["bash"],
|
|
1971
|
-
);
|
|
1972
|
-
|
|
1973
|
-
try {
|
|
1974
|
-
const result = await runToolCall(harness, {
|
|
1975
|
-
toolName: "bash",
|
|
1976
|
-
toolCallId: "bash-internal-allow",
|
|
1977
|
-
input: { command: "cat src/index.ts" },
|
|
1978
|
-
});
|
|
1979
|
-
|
|
1980
|
-
expect(result).toEqual({});
|
|
1981
|
-
} finally {
|
|
1982
|
-
await harness.cleanup();
|
|
1983
|
-
}
|
|
1984
|
-
});
|
|
1985
|
-
|
|
1986
|
-
test("tool_call prompts for bash command with external path when external_directory is ask", async () => {
|
|
1987
|
-
const harness = createToolCallHarness(
|
|
1988
|
-
{
|
|
1989
|
-
permission: { "*": "allow", external_directory: "ask" },
|
|
1990
|
-
},
|
|
1991
|
-
["bash"],
|
|
1992
|
-
);
|
|
1993
|
-
|
|
1994
|
-
try {
|
|
1995
|
-
const result = await runToolCall(harness, {
|
|
1996
|
-
toolName: "bash",
|
|
1997
|
-
toolCallId: "bash-external-ask-no-ui",
|
|
1998
|
-
input: { command: "cat /etc/hosts" },
|
|
1999
|
-
});
|
|
2000
|
-
|
|
2001
|
-
// No UI available in default harness, so it should block
|
|
2002
|
-
expect(result.block).toBe(true);
|
|
2003
|
-
expect(String(result.reason)).toMatch(
|
|
2004
|
-
/requires approval.*no interactive UI/i,
|
|
2005
|
-
);
|
|
2006
|
-
} finally {
|
|
2007
|
-
await harness.cleanup();
|
|
2008
|
-
}
|
|
2009
|
-
});
|
|
2010
|
-
|
|
2011
|
-
test("tool_call allows bash command with external path when external_directory is allow", async () => {
|
|
2012
|
-
const harness = createToolCallHarness(
|
|
2013
|
-
{
|
|
2014
|
-
permission: { "*": "allow", external_directory: "allow" },
|
|
2015
|
-
},
|
|
2016
|
-
["bash"],
|
|
2017
|
-
);
|
|
2018
|
-
|
|
2019
|
-
try {
|
|
2020
|
-
const result = await runToolCall(harness, {
|
|
2021
|
-
toolName: "bash",
|
|
2022
|
-
toolCallId: "bash-external-allow",
|
|
2023
|
-
input: { command: "cat /etc/hosts" },
|
|
2024
|
-
});
|
|
2025
|
-
|
|
2026
|
-
// Should pass through to normal bash permission (which is also allow)
|
|
2027
|
-
expect(result).toEqual({});
|
|
2028
|
-
} finally {
|
|
2029
|
-
await harness.cleanup();
|
|
2030
|
-
}
|
|
2031
|
-
});
|
|
2032
|
-
|
|
2033
|
-
test("tool_call applies bash pattern permissions after external_directory allow", async () => {
|
|
2034
|
-
const harness = createToolCallHarness(
|
|
2035
|
-
{
|
|
2036
|
-
permission: {
|
|
2037
|
-
"*": "allow",
|
|
2038
|
-
external_directory: "allow",
|
|
2039
|
-
bash: { "*": "allow", "cat *": "deny" },
|
|
2040
|
-
},
|
|
2041
|
-
},
|
|
2042
|
-
["bash"],
|
|
2043
|
-
);
|
|
2044
|
-
|
|
2045
|
-
try {
|
|
2046
|
-
const result = await runToolCall(harness, {
|
|
2047
|
-
toolName: "bash",
|
|
2048
|
-
toolCallId: "bash-pattern-deny-after-ext-allow",
|
|
2049
|
-
input: { command: "cat /etc/hosts" },
|
|
2050
|
-
});
|
|
2051
|
-
|
|
2052
|
-
// external_directory allows, but bash pattern denies
|
|
2053
|
-
expect(result.block).toBe(true);
|
|
2054
|
-
expect(String(result.reason)).toMatch(/not permitted/i);
|
|
2055
|
-
} finally {
|
|
2056
|
-
await harness.cleanup();
|
|
2057
|
-
}
|
|
2058
|
-
});
|
|
2059
|
-
|
|
2060
|
-
test("generic ask prompts include serialized tool input for informed approval", async () => {
|
|
2061
|
-
const harness = createToolCallHarness(
|
|
2062
|
-
{
|
|
2063
|
-
permission: { "*": "ask" },
|
|
2064
|
-
},
|
|
2065
|
-
["weather_lookup"],
|
|
2066
|
-
);
|
|
2067
|
-
|
|
2068
|
-
try {
|
|
2069
|
-
const result = await runToolCall(
|
|
2070
|
-
harness,
|
|
2071
|
-
{
|
|
2072
|
-
toolName: "weather_lookup",
|
|
2073
|
-
toolCallId: "generic-tool-input",
|
|
2074
|
-
input: { city: "Chicago", units: "metric" },
|
|
2075
|
-
},
|
|
2076
|
-
{ hasUI: true, selectResponse: "No" },
|
|
2077
|
-
);
|
|
2078
|
-
|
|
2079
|
-
expect(result.block).toBe(true);
|
|
2080
|
-
expect(harness.prompts.length).toBe(1);
|
|
2081
|
-
expect(harness.prompts[0]).toMatch(/weather_lookup/);
|
|
2082
|
-
expect(harness.prompts[0]).toMatch(/\{"city":"Chicago","units":"metric"\}/);
|
|
2083
|
-
} finally {
|
|
2084
|
-
await harness.cleanup();
|
|
2085
|
-
}
|
|
2086
|
-
});
|
|
2087
|
-
|
|
2088
|
-
test("getResolvedPolicyPaths returns correct paths and existence when files exist", () => {
|
|
2089
|
-
const tempDir = mkdtempSync(join(tmpdir(), "policy-paths-exist-"));
|
|
2090
|
-
try {
|
|
2091
|
-
const globalConfigPath = join(tempDir, "pi-permissions.jsonc");
|
|
2092
|
-
const agentsDir = join(tempDir, "agents");
|
|
2093
|
-
const projectConfigPath = join(tempDir, "project", "pi-permissions.jsonc");
|
|
2094
|
-
const projectAgentsDir = join(tempDir, "project", "agents");
|
|
2095
|
-
|
|
2096
|
-
writeFileSync(globalConfigPath, "{}", "utf-8");
|
|
2097
|
-
mkdirSync(agentsDir, { recursive: true });
|
|
2098
|
-
mkdirSync(join(tempDir, "project"), { recursive: true });
|
|
2099
|
-
writeFileSync(projectConfigPath, "{}", "utf-8");
|
|
2100
|
-
mkdirSync(projectAgentsDir, { recursive: true });
|
|
2101
|
-
|
|
2102
|
-
const pm = new PermissionManager({
|
|
2103
|
-
globalConfigPath,
|
|
2104
|
-
agentsDir,
|
|
2105
|
-
projectGlobalConfigPath: projectConfigPath,
|
|
2106
|
-
projectAgentsDir,
|
|
2107
|
-
});
|
|
2108
|
-
|
|
2109
|
-
const result = pm.getResolvedPolicyPaths();
|
|
2110
|
-
|
|
2111
|
-
expect(result.globalConfigPath).toBe(globalConfigPath);
|
|
2112
|
-
expect(result.globalConfigExists).toBe(true);
|
|
2113
|
-
expect(result.projectConfigPath).toBe(projectConfigPath);
|
|
2114
|
-
expect(result.projectConfigExists).toBe(true);
|
|
2115
|
-
expect(result.agentsDir).toBe(agentsDir);
|
|
2116
|
-
expect(result.agentsDirExists).toBe(true);
|
|
2117
|
-
expect(result.projectAgentsDir).toBe(projectAgentsDir);
|
|
2118
|
-
expect(result.projectAgentsDirExists).toBe(true);
|
|
2119
|
-
} finally {
|
|
2120
|
-
rmSync(tempDir, { recursive: true, force: true });
|
|
2121
|
-
}
|
|
2122
|
-
});
|
|
2123
|
-
|
|
2124
|
-
test("getResolvedPolicyPaths returns false for missing files and null for absent project paths", () => {
|
|
2125
|
-
const tempDir = mkdtempSync(join(tmpdir(), "policy-paths-missing-"));
|
|
2126
|
-
try {
|
|
2127
|
-
const globalConfigPath = join(tempDir, "does-not-exist.jsonc");
|
|
2128
|
-
const agentsDir = join(tempDir, "no-agents");
|
|
2129
|
-
|
|
2130
|
-
const pm = new PermissionManager({
|
|
2131
|
-
globalConfigPath,
|
|
2132
|
-
agentsDir,
|
|
2133
|
-
});
|
|
2134
|
-
|
|
2135
|
-
const result = pm.getResolvedPolicyPaths();
|
|
2136
|
-
|
|
2137
|
-
expect(result.globalConfigPath).toBe(globalConfigPath);
|
|
2138
|
-
expect(result.globalConfigExists).toBe(false);
|
|
2139
|
-
expect(result.projectConfigPath).toBe(null);
|
|
2140
|
-
expect(result.projectConfigExists).toBe(false);
|
|
2141
|
-
expect(result.agentsDir).toBe(agentsDir);
|
|
2142
|
-
expect(result.agentsDirExists).toBe(false);
|
|
2143
|
-
expect(result.projectAgentsDir).toBe(null);
|
|
2144
|
-
expect(result.projectAgentsDirExists).toBe(false);
|
|
2145
|
-
} finally {
|
|
2146
|
-
rmSync(tempDir, { recursive: true, force: true });
|
|
2147
|
-
}
|
|
2148
|
-
});
|
|
2149
|
-
|
|
2150
|
-
// --- config issues tests ---
|
|
2151
|
-
|
|
2152
|
-
test("PermissionManager.getConfigIssues returns empty array for clean config", () => {
|
|
2153
|
-
const config: ScopeConfig = {
|
|
2154
|
-
permission: { "*": "ask", external_directory: "ask" },
|
|
2155
|
-
};
|
|
2156
|
-
const { manager, cleanup } = createManager(config);
|
|
2157
|
-
try {
|
|
2158
|
-
const issues = manager.getConfigIssues();
|
|
2159
|
-
expect(issues.length).toBe(0);
|
|
2160
|
-
} finally {
|
|
2161
|
-
cleanup();
|
|
2162
|
-
}
|
|
2163
|
-
});
|
|
2164
|
-
|
|
2165
|
-
test("PermissionManager.getConfigIssues returns empty array for empty config", () => {
|
|
2166
|
-
const { manager, cleanup } = createManager({});
|
|
2167
|
-
try {
|
|
2168
|
-
const issues = manager.getConfigIssues();
|
|
2169
|
-
expect(issues.length).toBe(0);
|
|
2170
|
-
} finally {
|
|
2171
|
-
cleanup();
|
|
2172
|
-
}
|
|
2173
|
-
});
|
|
2174
|
-
|
|
2175
|
-
// ---------------------------------------------------------------------------
|
|
2176
|
-
// Session-scoped approval tests (#45)
|
|
2177
|
-
// ---------------------------------------------------------------------------
|
|
2178
|
-
|
|
2179
|
-
test("session approval: first prompt with 'Yes, for this session' skips subsequent prompts under same prefix", async () => {
|
|
2180
|
-
const rootDir = mkdtempSync(join(tmpdir(), "pi-session-approval-"));
|
|
2181
|
-
const cwd = join(rootDir, "repo");
|
|
2182
|
-
const siblingDir = join(rootDir, "sibling-project");
|
|
2183
|
-
mkdirSync(cwd, { recursive: true });
|
|
2184
|
-
mkdirSync(siblingDir, { recursive: true });
|
|
2185
|
-
|
|
2186
|
-
const harness = createToolCallHarness(
|
|
2187
|
-
{
|
|
2188
|
-
permission: { "*": "allow", external_directory: "ask" },
|
|
2189
|
-
},
|
|
2190
|
-
["read", "grep"],
|
|
2191
|
-
{ cwd },
|
|
2192
|
-
);
|
|
2193
|
-
|
|
2194
|
-
try {
|
|
2195
|
-
// First access — user selects "Yes, for this session"
|
|
2196
|
-
const result1 = await runToolCall(
|
|
2197
|
-
harness,
|
|
2198
|
-
{
|
|
2199
|
-
toolName: "read",
|
|
2200
|
-
toolCallId: "ext-session-1",
|
|
2201
|
-
input: { path: join(siblingDir, "src", "foo.ts") },
|
|
2202
|
-
},
|
|
2203
|
-
{ hasUI: true, selectResponse: "Yes, for this session" },
|
|
2204
|
-
);
|
|
2205
|
-
expect(result1).toEqual({});
|
|
2206
|
-
expect(harness.prompts.length).toBe(1);
|
|
2207
|
-
|
|
2208
|
-
// Second access under same prefix — should skip prompt
|
|
2209
|
-
const result2 = await runToolCall(
|
|
2210
|
-
harness,
|
|
2211
|
-
{
|
|
2212
|
-
toolName: "read",
|
|
2213
|
-
toolCallId: "ext-session-2",
|
|
2214
|
-
input: { path: join(siblingDir, "src", "bar.ts") },
|
|
2215
|
-
},
|
|
2216
|
-
{ hasUI: true, selectResponse: "Yes, for this session" },
|
|
2217
|
-
);
|
|
2218
|
-
expect(result2).toEqual({});
|
|
2219
|
-
// No new prompt — still just the original one
|
|
2220
|
-
expect(harness.prompts.length).toBe(1);
|
|
2221
|
-
|
|
2222
|
-
// Third access with different tool under same prefix — also skipped
|
|
2223
|
-
const result3 = await runToolCall(
|
|
2224
|
-
harness,
|
|
2225
|
-
{
|
|
2226
|
-
toolName: "grep",
|
|
2227
|
-
toolCallId: "ext-session-3",
|
|
2228
|
-
input: { pattern: "needle", path: join(siblingDir, "src", "baz.ts") },
|
|
2229
|
-
},
|
|
2230
|
-
{ hasUI: true, selectResponse: "Yes, for this session" },
|
|
2231
|
-
);
|
|
2232
|
-
expect(result3).toEqual({});
|
|
2233
|
-
expect(harness.prompts.length).toBe(1);
|
|
2234
|
-
} finally {
|
|
2235
|
-
await harness.cleanup();
|
|
2236
|
-
rmSync(rootDir, { recursive: true, force: true });
|
|
2237
|
-
}
|
|
2238
|
-
});
|
|
2239
|
-
|
|
2240
|
-
test("session approval: different directory prefix still prompts", async () => {
|
|
2241
|
-
const rootDir = mkdtempSync(join(tmpdir(), "pi-session-approval-"));
|
|
2242
|
-
const cwd = join(rootDir, "repo");
|
|
2243
|
-
const siblingA = join(rootDir, "sibling-a");
|
|
2244
|
-
const siblingB = join(rootDir, "sibling-b");
|
|
2245
|
-
mkdirSync(cwd, { recursive: true });
|
|
2246
|
-
mkdirSync(siblingA, { recursive: true });
|
|
2247
|
-
mkdirSync(siblingB, { recursive: true });
|
|
2248
|
-
|
|
2249
|
-
const harness = createToolCallHarness(
|
|
2250
|
-
{
|
|
2251
|
-
permission: { "*": "allow", external_directory: "ask" },
|
|
2252
|
-
},
|
|
2253
|
-
["read"],
|
|
2254
|
-
{ cwd },
|
|
2255
|
-
);
|
|
2256
|
-
|
|
2257
|
-
try {
|
|
2258
|
-
// Approve sibling-a/src/ for session
|
|
2259
|
-
await runToolCall(
|
|
2260
|
-
harness,
|
|
2261
|
-
{
|
|
2262
|
-
toolName: "read",
|
|
2263
|
-
toolCallId: "ext-diff-1",
|
|
2264
|
-
input: { path: join(siblingA, "src", "foo.ts") },
|
|
2265
|
-
},
|
|
2266
|
-
{ hasUI: true, selectResponse: "Yes, for this session" },
|
|
2267
|
-
);
|
|
2268
|
-
expect(harness.prompts.length).toBe(1);
|
|
2269
|
-
|
|
2270
|
-
// Access sibling-b — different prefix, should prompt again
|
|
2271
|
-
await runToolCall(
|
|
2272
|
-
harness,
|
|
2273
|
-
{
|
|
2274
|
-
toolName: "read",
|
|
2275
|
-
toolCallId: "ext-diff-2",
|
|
2276
|
-
input: { path: join(siblingB, "src", "bar.ts") },
|
|
2277
|
-
},
|
|
2278
|
-
{ hasUI: true, selectResponse: "Yes" },
|
|
2279
|
-
);
|
|
2280
|
-
expect(harness.prompts.length).toBe(2);
|
|
2281
|
-
} finally {
|
|
2282
|
-
await harness.cleanup();
|
|
2283
|
-
rmSync(rootDir, { recursive: true, force: true });
|
|
2284
|
-
}
|
|
2285
|
-
});
|
|
2286
|
-
|
|
2287
|
-
test("session approval: session_shutdown clears session approvals", async () => {
|
|
2288
|
-
const rootDir = mkdtempSync(join(tmpdir(), "pi-session-approval-"));
|
|
2289
|
-
const cwd = join(rootDir, "repo");
|
|
2290
|
-
const siblingDir = join(rootDir, "sibling");
|
|
2291
|
-
mkdirSync(cwd, { recursive: true });
|
|
2292
|
-
mkdirSync(siblingDir, { recursive: true });
|
|
2293
|
-
|
|
2294
|
-
const harness = createToolCallHarness(
|
|
2295
|
-
{
|
|
2296
|
-
permission: { "*": "allow", external_directory: "ask" },
|
|
2297
|
-
},
|
|
2298
|
-
["read"],
|
|
2299
|
-
{ cwd },
|
|
2300
|
-
);
|
|
2301
|
-
|
|
2302
|
-
try {
|
|
2303
|
-
// Approve for session
|
|
2304
|
-
await runToolCall(
|
|
2305
|
-
harness,
|
|
2306
|
-
{
|
|
2307
|
-
toolName: "read",
|
|
2308
|
-
toolCallId: "ext-shutdown-1",
|
|
2309
|
-
input: { path: join(siblingDir, "src", "foo.ts") },
|
|
2310
|
-
},
|
|
2311
|
-
{ hasUI: true, selectResponse: "Yes, for this session" },
|
|
2312
|
-
);
|
|
2313
|
-
expect(harness.prompts.length).toBe(1);
|
|
2314
|
-
|
|
2315
|
-
// Trigger session_shutdown (clears cache)
|
|
2316
|
-
const shutdownCtx = createMockContext(cwd, harness.prompts, {
|
|
2317
|
-
hasUI: true,
|
|
2318
|
-
selectResponse: "Yes",
|
|
2319
|
-
});
|
|
2320
|
-
await harness.pi.fire("session_shutdown", {}, shutdownCtx);
|
|
2321
|
-
|
|
2322
|
-
// Access same path again — should prompt because cache was cleared
|
|
2323
|
-
const result = await runToolCall(
|
|
2324
|
-
harness,
|
|
2325
|
-
{
|
|
2326
|
-
toolName: "read",
|
|
2327
|
-
toolCallId: "ext-shutdown-2",
|
|
2328
|
-
input: { path: join(siblingDir, "src", "foo.ts") },
|
|
2329
|
-
},
|
|
2330
|
-
{ hasUI: true, selectResponse: "Yes" },
|
|
2331
|
-
);
|
|
2332
|
-
expect(result).toEqual({});
|
|
2333
|
-
expect(harness.prompts.length).toBe(2);
|
|
2334
|
-
} finally {
|
|
2335
|
-
await harness.cleanup();
|
|
2336
|
-
rmSync(rootDir, { recursive: true, force: true });
|
|
2337
|
-
}
|
|
2338
|
-
});
|
|
2339
|
-
|
|
2340
|
-
test("session approval: bash external directory with 'Yes, for this session' skips subsequent prompts", async () => {
|
|
2341
|
-
const rootDir = mkdtempSync(join(tmpdir(), "pi-session-approval-"));
|
|
2342
|
-
const cwd = join(rootDir, "repo");
|
|
2343
|
-
mkdirSync(cwd, { recursive: true });
|
|
2344
|
-
|
|
2345
|
-
const harness = createToolCallHarness(
|
|
2346
|
-
{
|
|
2347
|
-
permission: { "*": "allow", external_directory: "ask" },
|
|
2348
|
-
},
|
|
2349
|
-
["bash"],
|
|
2350
|
-
{ cwd },
|
|
2351
|
-
);
|
|
2352
|
-
|
|
2353
|
-
try {
|
|
2354
|
-
const externalPath = join(rootDir, "other-project", "src");
|
|
2355
|
-
// First bash command referencing external path
|
|
2356
|
-
const result1 = await runToolCall(
|
|
2357
|
-
harness,
|
|
2358
|
-
{
|
|
2359
|
-
toolName: "bash",
|
|
2360
|
-
toolCallId: "bash-session-1",
|
|
2361
|
-
input: { command: `ls ${externalPath}/foo.ts` },
|
|
2362
|
-
},
|
|
2363
|
-
{ hasUI: true, selectResponse: "Yes, for this session" },
|
|
2364
|
-
);
|
|
2365
|
-
expect(result1).toEqual({});
|
|
2366
|
-
expect(harness.prompts.length).toBe(1);
|
|
2367
|
-
|
|
2368
|
-
// Second bash command referencing path under same prefix — skips prompt
|
|
2369
|
-
const result2 = await runToolCall(
|
|
2370
|
-
harness,
|
|
2371
|
-
{
|
|
2372
|
-
toolName: "bash",
|
|
2373
|
-
toolCallId: "bash-session-2",
|
|
2374
|
-
input: { command: `cat ${externalPath}/bar.ts` },
|
|
2375
|
-
},
|
|
2376
|
-
{ hasUI: true, selectResponse: "Yes, for this session" },
|
|
2377
|
-
);
|
|
2378
|
-
expect(result2).toEqual({});
|
|
2379
|
-
expect(harness.prompts.length).toBe(1);
|
|
2380
|
-
} finally {
|
|
2381
|
-
await harness.cleanup();
|
|
2382
|
-
rmSync(rootDir, { recursive: true, force: true });
|
|
2383
|
-
}
|
|
2384
|
-
});
|
|
2385
|
-
|
|
2386
|
-
test("session approval: regular 'Yes' does not create session approval", async () => {
|
|
2387
|
-
const rootDir = mkdtempSync(join(tmpdir(), "pi-session-approval-"));
|
|
2388
|
-
const cwd = join(rootDir, "repo");
|
|
2389
|
-
const siblingDir = join(rootDir, "sibling");
|
|
2390
|
-
mkdirSync(cwd, { recursive: true });
|
|
2391
|
-
mkdirSync(siblingDir, { recursive: true });
|
|
2392
|
-
|
|
2393
|
-
const harness = createToolCallHarness(
|
|
2394
|
-
{
|
|
2395
|
-
permission: { "*": "allow", external_directory: "ask" },
|
|
2396
|
-
},
|
|
2397
|
-
["read"],
|
|
2398
|
-
{ cwd },
|
|
2399
|
-
);
|
|
2400
|
-
|
|
2401
|
-
try {
|
|
2402
|
-
// Approve once with "Yes" (not session)
|
|
2403
|
-
await runToolCall(
|
|
2404
|
-
harness,
|
|
2405
|
-
{
|
|
2406
|
-
toolName: "read",
|
|
2407
|
-
toolCallId: "ext-once-1",
|
|
2408
|
-
input: { path: join(siblingDir, "src", "foo.ts") },
|
|
2409
|
-
},
|
|
2410
|
-
{ hasUI: true, selectResponse: "Yes" },
|
|
2411
|
-
);
|
|
2412
|
-
expect(harness.prompts.length).toBe(1);
|
|
2413
|
-
|
|
2414
|
-
// Same prefix — should still prompt since we used "Yes" not session
|
|
2415
|
-
await runToolCall(
|
|
2416
|
-
harness,
|
|
2417
|
-
{
|
|
2418
|
-
toolName: "read",
|
|
2419
|
-
toolCallId: "ext-once-2",
|
|
2420
|
-
input: { path: join(siblingDir, "src", "bar.ts") },
|
|
2421
|
-
},
|
|
2422
|
-
{ hasUI: true, selectResponse: "Yes" },
|
|
2423
|
-
);
|
|
2424
|
-
expect(harness.prompts.length).toBe(2);
|
|
2425
|
-
} finally {
|
|
2426
|
-
await harness.cleanup();
|
|
2427
|
-
rmSync(rootDir, { recursive: true, force: true });
|
|
2428
|
-
}
|
|
2429
|
-
});
|
|
2430
|
-
|
|
2431
|
-
// ---------------------------------------------------------------------------
|
|
2432
|
-
// Session-aware checkPermission() integration
|
|
2433
|
-
// ---------------------------------------------------------------------------
|
|
2434
|
-
|
|
2435
|
-
test("checkPermission returns source 'session' when session rules cover the external_directory path", () => {
|
|
2436
|
-
const { manager, cleanup } = createManager({
|
|
2437
|
-
permission: { "*": "allow" },
|
|
2438
|
-
});
|
|
2439
|
-
|
|
2440
|
-
try {
|
|
2441
|
-
const sessionRules = [
|
|
2442
|
-
{
|
|
2443
|
-
surface: "external_directory",
|
|
2444
|
-
pattern: "/other/project/*",
|
|
2445
|
-
action: "allow" as const,
|
|
2446
|
-
layer: "session" as const,
|
|
2447
|
-
origin: "session" as const,
|
|
2448
|
-
},
|
|
2449
|
-
];
|
|
2450
|
-
|
|
2451
|
-
const result = manager.checkPermission(
|
|
2452
|
-
"external_directory",
|
|
2453
|
-
{ path: "/other/project/src/foo.ts" },
|
|
2454
|
-
undefined,
|
|
2455
|
-
sessionRules,
|
|
2456
|
-
);
|
|
2457
|
-
expect(result.state).toBe("allow");
|
|
2458
|
-
expect(result.source).toBe("session");
|
|
2459
|
-
expect(result.matchedPattern).toBe("/other/project/*");
|
|
2460
|
-
} finally {
|
|
2461
|
-
cleanup();
|
|
2462
|
-
}
|
|
2463
|
-
});
|
|
2464
|
-
|
|
2465
|
-
test("checkPermission falls back to config policy when session rules do not cover the path", () => {
|
|
2466
|
-
const { manager, cleanup } = createManager({
|
|
2467
|
-
permission: { "*": "allow", external_directory: "deny" },
|
|
2468
|
-
});
|
|
2469
|
-
|
|
2470
|
-
try {
|
|
2471
|
-
const sessionRules = [
|
|
2472
|
-
{
|
|
2473
|
-
surface: "external_directory",
|
|
2474
|
-
pattern: "/other/project/*",
|
|
2475
|
-
action: "allow" as const,
|
|
2476
|
-
layer: "session" as const,
|
|
2477
|
-
origin: "session" as const,
|
|
2478
|
-
},
|
|
2479
|
-
];
|
|
2480
|
-
|
|
2481
|
-
// Path NOT under /other/project/ — session rules don't match.
|
|
2482
|
-
const result = manager.checkPermission(
|
|
2483
|
-
"external_directory",
|
|
2484
|
-
{ path: "/completely/different/path.ts" },
|
|
2485
|
-
undefined,
|
|
2486
|
-
sessionRules,
|
|
2487
|
-
);
|
|
2488
|
-
expect(result.state).toBe("deny");
|
|
2489
|
-
expect(result.source).toBe("special");
|
|
2490
|
-
} finally {
|
|
2491
|
-
cleanup();
|
|
2492
|
-
}
|
|
2493
|
-
});
|
|
2494
|
-
|
|
2495
|
-
test("checkPermission with empty session rules is identical to call without sessionRules arg", () => {
|
|
2496
|
-
const { manager, cleanup } = createManager({
|
|
2497
|
-
permission: { "*": "allow", external_directory: "deny" },
|
|
2498
|
-
});
|
|
2499
|
-
|
|
2500
|
-
try {
|
|
2501
|
-
const withEmpty = manager.checkPermission(
|
|
2502
|
-
"external_directory",
|
|
2503
|
-
{ path: "/other/project/foo.ts" },
|
|
2504
|
-
undefined,
|
|
2505
|
-
[],
|
|
2506
|
-
);
|
|
2507
|
-
const withoutArg = manager.checkPermission("external_directory", {
|
|
2508
|
-
path: "/other/project/foo.ts",
|
|
2509
|
-
});
|
|
2510
|
-
const expected: PermissionCheckResult = {
|
|
2511
|
-
toolName: "external_directory",
|
|
2512
|
-
state: "deny",
|
|
2513
|
-
matchedPattern: "*",
|
|
2514
|
-
source: "special",
|
|
2515
|
-
origin: "global",
|
|
2516
|
-
};
|
|
2517
|
-
expect(withEmpty).toEqual(expected);
|
|
2518
|
-
expect(withoutArg).toEqual(expected);
|
|
2519
|
-
} finally {
|
|
2520
|
-
cleanup();
|
|
2521
|
-
}
|
|
2522
|
-
});
|
|
2523
|
-
|
|
2524
|
-
test("session rules for one surface do not affect checks on other surfaces", () => {
|
|
2525
|
-
const { manager, cleanup } = createManager({
|
|
2526
|
-
// Empty permission: universal default is "ask" from DEFAULT_UNIVERSAL_FALLBACK.
|
|
2527
|
-
permission: {},
|
|
2528
|
-
});
|
|
2529
|
-
|
|
2530
|
-
try {
|
|
2531
|
-
const sessionRules = [
|
|
2532
|
-
{
|
|
2533
|
-
surface: "external_directory",
|
|
2534
|
-
pattern: "/other/project/*",
|
|
2535
|
-
action: "allow" as const,
|
|
2536
|
-
layer: "session" as const,
|
|
2537
|
-
origin: "session" as const,
|
|
2538
|
-
},
|
|
2539
|
-
];
|
|
2540
|
-
|
|
2541
|
-
// Bash check — session rules should not affect bash decisions.
|
|
2542
|
-
const bashResult = manager.checkPermission(
|
|
2543
|
-
"bash",
|
|
2544
|
-
{ command: "git status" },
|
|
2545
|
-
undefined,
|
|
2546
|
-
sessionRules,
|
|
2547
|
-
);
|
|
2548
|
-
expect(bashResult.state).toBe("ask");
|
|
2549
|
-
expect(bashResult.source).toBe("bash");
|
|
2550
|
-
|
|
2551
|
-
// MCP check — session rules should not affect MCP decisions.
|
|
2552
|
-
const mcpResult = manager.checkPermission(
|
|
2553
|
-
"mcp",
|
|
2554
|
-
{ tool: "exa:search" },
|
|
2555
|
-
undefined,
|
|
2556
|
-
sessionRules,
|
|
2557
|
-
);
|
|
2558
|
-
expect(mcpResult.state).toBe("ask");
|
|
2559
|
-
expect(mcpResult.source).toBe("default");
|
|
2560
|
-
} finally {
|
|
2561
|
-
cleanup();
|
|
2562
|
-
}
|
|
2563
|
-
});
|
|
2564
|
-
|
|
2565
|
-
test("session rules override config deny for external_directory", () => {
|
|
2566
|
-
const { manager, cleanup } = createManager({
|
|
2567
|
-
permission: { "*": "allow", external_directory: "deny" },
|
|
2568
|
-
});
|
|
2569
|
-
|
|
2570
|
-
try {
|
|
2571
|
-
const sessionRules = [
|
|
2572
|
-
{
|
|
2573
|
-
surface: "external_directory",
|
|
2574
|
-
pattern: "/other/project/*",
|
|
2575
|
-
action: "allow" as const,
|
|
2576
|
-
layer: "session" as const,
|
|
2577
|
-
origin: "session" as const,
|
|
2578
|
-
},
|
|
2579
|
-
];
|
|
2580
|
-
|
|
2581
|
-
// Session approval overrides config deny for the covered path.
|
|
2582
|
-
const result = manager.checkPermission(
|
|
2583
|
-
"external_directory",
|
|
2584
|
-
{ path: "/other/project/src/foo.ts" },
|
|
2585
|
-
undefined,
|
|
2586
|
-
sessionRules,
|
|
2587
|
-
);
|
|
2588
|
-
expect(result.state).toBe("allow");
|
|
2589
|
-
expect(result.source).toBe("session");
|
|
2590
|
-
} finally {
|
|
2591
|
-
cleanup();
|
|
2592
|
-
}
|
|
2593
|
-
});
|
|
2594
|
-
|
|
2595
|
-
// ── Session rule evaluation for all surfaces ─────────────────────────────
|
|
2596
|
-
|
|
2597
|
-
test("checkPermission returns source 'session' for bash when session rules match", () => {
|
|
2598
|
-
const { manager, cleanup } = createManager({ permission: {} });
|
|
2599
|
-
|
|
2600
|
-
try {
|
|
2601
|
-
const sessionRules = [
|
|
2602
|
-
{
|
|
2603
|
-
surface: "bash",
|
|
2604
|
-
pattern: "git *",
|
|
2605
|
-
action: "allow" as const,
|
|
2606
|
-
layer: "session" as const,
|
|
2607
|
-
origin: "session" as const,
|
|
2608
|
-
},
|
|
2609
|
-
];
|
|
2610
|
-
|
|
2611
|
-
const result = manager.checkPermission(
|
|
2612
|
-
"bash",
|
|
2613
|
-
{ command: "git status --short" },
|
|
2614
|
-
undefined,
|
|
2615
|
-
sessionRules,
|
|
2616
|
-
);
|
|
2617
|
-
expect(result.state).toBe("allow");
|
|
2618
|
-
expect(result.source).toBe("session");
|
|
2619
|
-
expect(result.matchedPattern).toBe("git *");
|
|
2620
|
-
} finally {
|
|
2621
|
-
cleanup();
|
|
2622
|
-
}
|
|
2623
|
-
});
|
|
2624
|
-
|
|
2625
|
-
test("checkPermission returns source 'session' for bash when session rule is exact match", () => {
|
|
2626
|
-
const { manager, cleanup } = createManager({ permission: {} });
|
|
2627
|
-
|
|
2628
|
-
try {
|
|
2629
|
-
const sessionRules = [
|
|
2630
|
-
{
|
|
2631
|
-
surface: "bash",
|
|
2632
|
-
pattern: "ls",
|
|
2633
|
-
action: "allow" as const,
|
|
2634
|
-
layer: "session" as const,
|
|
2635
|
-
origin: "session" as const,
|
|
2636
|
-
},
|
|
2637
|
-
];
|
|
2638
|
-
|
|
2639
|
-
const result = manager.checkPermission(
|
|
2640
|
-
"bash",
|
|
2641
|
-
{ command: "ls" },
|
|
2642
|
-
undefined,
|
|
2643
|
-
sessionRules,
|
|
2644
|
-
);
|
|
2645
|
-
expect(result.state).toBe("allow");
|
|
2646
|
-
expect(result.source).toBe("session");
|
|
2647
|
-
} finally {
|
|
2648
|
-
cleanup();
|
|
2649
|
-
}
|
|
2650
|
-
});
|
|
2651
|
-
|
|
2652
|
-
test("checkPermission falls back to config for bash when session rules do not match the command", () => {
|
|
2653
|
-
const { manager, cleanup } = createManager({ permission: { bash: "deny" } });
|
|
2654
|
-
|
|
2655
|
-
try {
|
|
2656
|
-
const sessionRules = [
|
|
2657
|
-
{
|
|
2658
|
-
surface: "bash",
|
|
2659
|
-
pattern: "git *",
|
|
2660
|
-
action: "allow" as const,
|
|
2661
|
-
layer: "session" as const,
|
|
2662
|
-
origin: "session" as const,
|
|
2663
|
-
},
|
|
2664
|
-
];
|
|
2665
|
-
|
|
2666
|
-
const result = manager.checkPermission(
|
|
2667
|
-
"bash",
|
|
2668
|
-
{ command: "npm run build" },
|
|
2669
|
-
undefined,
|
|
2670
|
-
sessionRules,
|
|
2671
|
-
);
|
|
2672
|
-
expect(result.state).toBe("deny");
|
|
2673
|
-
expect(result.source).toBe("bash");
|
|
2674
|
-
} finally {
|
|
2675
|
-
cleanup();
|
|
2676
|
-
}
|
|
2677
|
-
});
|
|
2678
|
-
|
|
2679
|
-
test("checkPermission returns source 'session' for mcp when session rules match the target", () => {
|
|
2680
|
-
const { manager, cleanup } = createManager({ permission: {} });
|
|
2681
|
-
|
|
2682
|
-
try {
|
|
2683
|
-
const sessionRules = [
|
|
2684
|
-
{
|
|
2685
|
-
surface: "mcp",
|
|
2686
|
-
pattern: "exa:*",
|
|
2687
|
-
action: "allow" as const,
|
|
2688
|
-
layer: "session" as const,
|
|
2689
|
-
origin: "session" as const,
|
|
2690
|
-
},
|
|
2691
|
-
];
|
|
2692
|
-
|
|
2693
|
-
const result = manager.checkPermission(
|
|
2694
|
-
"mcp",
|
|
2695
|
-
{ tool: "exa:search" },
|
|
2696
|
-
undefined,
|
|
2697
|
-
sessionRules,
|
|
2698
|
-
);
|
|
2699
|
-
expect(result.state).toBe("allow");
|
|
2700
|
-
expect(result.source).toBe("session");
|
|
2701
|
-
} finally {
|
|
2702
|
-
cleanup();
|
|
2703
|
-
}
|
|
2704
|
-
});
|
|
2705
|
-
|
|
2706
|
-
test("checkPermission returns source 'session' for skill when session rules match", () => {
|
|
2707
|
-
const { manager, cleanup } = createManager({ permission: {} });
|
|
2708
|
-
|
|
2709
|
-
try {
|
|
2710
|
-
const sessionRules = [
|
|
2711
|
-
{
|
|
2712
|
-
surface: "skill",
|
|
2713
|
-
pattern: "librarian",
|
|
2714
|
-
action: "allow" as const,
|
|
2715
|
-
layer: "session" as const,
|
|
2716
|
-
origin: "session" as const,
|
|
2717
|
-
},
|
|
2718
|
-
];
|
|
2719
|
-
|
|
2720
|
-
const result = manager.checkPermission(
|
|
2721
|
-
"skill",
|
|
2722
|
-
{ name: "librarian" },
|
|
2723
|
-
undefined,
|
|
2724
|
-
sessionRules,
|
|
2725
|
-
);
|
|
2726
|
-
expect(result.state).toBe("allow");
|
|
2727
|
-
expect(result.source).toBe("session");
|
|
2728
|
-
expect(result.matchedPattern).toBe("librarian");
|
|
2729
|
-
} finally {
|
|
2730
|
-
cleanup();
|
|
2731
|
-
}
|
|
2732
|
-
});
|
|
2733
|
-
|
|
2734
|
-
test("checkPermission returns source 'session' for tool surface when session rules match", () => {
|
|
2735
|
-
const { manager, cleanup } = createManager({ permission: {} });
|
|
2736
|
-
|
|
2737
|
-
try {
|
|
2738
|
-
const sessionRules = [
|
|
2739
|
-
{
|
|
2740
|
-
surface: "read",
|
|
2741
|
-
pattern: "*",
|
|
2742
|
-
action: "allow" as const,
|
|
2743
|
-
layer: "session" as const,
|
|
2744
|
-
origin: "session" as const,
|
|
2745
|
-
},
|
|
2746
|
-
];
|
|
2747
|
-
|
|
2748
|
-
const result = manager.checkPermission("read", {}, undefined, sessionRules);
|
|
2749
|
-
expect(result.state).toBe("allow");
|
|
2750
|
-
expect(result.source).toBe("session");
|
|
2751
|
-
} finally {
|
|
2752
|
-
cleanup();
|
|
2753
|
-
}
|
|
2754
|
-
});
|
|
2755
|
-
|
|
2756
|
-
test("bash session rules do not bleed into mcp checks", () => {
|
|
2757
|
-
const { manager, cleanup } = createManager({ permission: {} });
|
|
2758
|
-
|
|
2759
|
-
try {
|
|
2760
|
-
const sessionRules = [
|
|
2761
|
-
{
|
|
2762
|
-
surface: "bash",
|
|
2763
|
-
pattern: "git *",
|
|
2764
|
-
action: "allow" as const,
|
|
2765
|
-
layer: "session" as const,
|
|
2766
|
-
origin: "session" as const,
|
|
2767
|
-
},
|
|
2768
|
-
];
|
|
2769
|
-
|
|
2770
|
-
const result = manager.checkPermission(
|
|
2771
|
-
"mcp",
|
|
2772
|
-
{ tool: "exa:search" },
|
|
2773
|
-
undefined,
|
|
2774
|
-
sessionRules,
|
|
2775
|
-
);
|
|
2776
|
-
// bash session rule must not affect mcp surface
|
|
2777
|
-
expect(result.source).not.toBe("session");
|
|
2778
|
-
} finally {
|
|
2779
|
-
cleanup();
|
|
2780
|
-
}
|
|
2781
|
-
});
|
|
2782
|
-
|
|
2783
|
-
// Suppress unused import warning — PermissionState used in type annotations
|
|
2784
|
-
const _unused: PermissionState = "ask";
|
|
2785
|
-
void _unused;
|