@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.
@@ -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
+ });