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