@gotgenes/pi-permission-system 4.7.0 → 4.9.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 +35 -0
- package/README.md +43 -8
- package/config/config.example.json +6 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +12 -2
- package/src/expand-home.ts +28 -0
- package/src/extension-config.ts +13 -1
- package/src/external-directory.ts +96 -1
- package/src/handlers/tool-call.ts +87 -61
- package/src/runtime.ts +17 -0
- package/src/wildcard-matcher.ts +4 -1
- package/tests/bash-external-directory.test.ts +50 -0
- package/tests/expand-home.test.ts +93 -0
- package/tests/handlers/tool-call.test.ts +147 -0
- package/tests/permission-manager-unified.test.ts +74 -1
- package/tests/pi-infrastructure-read.test.ts +245 -0
- package/tests/runtime.test.ts +45 -0
- package/tests/wildcard-matcher.test.ts +58 -0
|
@@ -544,6 +544,56 @@ describe("extractExternalPathsFromBashCommand", () => {
|
|
|
544
544
|
expect(etcHostsCount).toBe(1);
|
|
545
545
|
});
|
|
546
546
|
});
|
|
547
|
+
|
|
548
|
+
describe("regex patterns are not mistaken for paths", () => {
|
|
549
|
+
test("grep -v with //.*pattern is not flagged", async () => {
|
|
550
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
551
|
+
'grep -n "glob" src/foo.ts 2>/dev/null | grep -v "//.*glob\\|globalConfig" | head -30',
|
|
552
|
+
cwd,
|
|
553
|
+
);
|
|
554
|
+
expect(result).toHaveLength(0);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("grep -v with //.*pattern without backslash-pipe is not flagged", async () => {
|
|
558
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
559
|
+
'grep -v "//.*foo" file.txt',
|
|
560
|
+
cwd,
|
|
561
|
+
);
|
|
562
|
+
expect(result).toHaveLength(0);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test("grep with backslash-pipe alternation is not flagged", async () => {
|
|
566
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
567
|
+
'grep "foo\\|bar\\|baz" src/file.ts',
|
|
568
|
+
cwd,
|
|
569
|
+
);
|
|
570
|
+
expect(result).toHaveLength(0);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("grep -E with ^/ anchored regex is not flagged", async () => {
|
|
574
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
575
|
+
'grep -E "^/usr/bin" file.txt',
|
|
576
|
+
cwd,
|
|
577
|
+
);
|
|
578
|
+
expect(result).toHaveLength(0);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("sed with regex containing slashes is not flagged", async () => {
|
|
582
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
583
|
+
'sed "s/foo.*/bar/g" file.txt',
|
|
584
|
+
cwd,
|
|
585
|
+
);
|
|
586
|
+
expect(result).toHaveLength(0);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("real external paths are still detected alongside regex args", async () => {
|
|
590
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
591
|
+
'grep -v "//.*pattern" /etc/hosts',
|
|
592
|
+
cwd,
|
|
593
|
+
);
|
|
594
|
+
expect(result).toContain("/etc/hosts");
|
|
595
|
+
});
|
|
596
|
+
});
|
|
547
597
|
});
|
|
548
598
|
|
|
549
599
|
describe("formatBashExternalDirectoryAskPrompt", () => {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const mockHomedir = vi.hoisted(() => vi.fn(() => "/home/testuser"));
|
|
5
|
+
|
|
6
|
+
vi.mock("node:os", () => ({
|
|
7
|
+
homedir: mockHomedir,
|
|
8
|
+
default: { homedir: mockHomedir },
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import { expandHomePath } from "../src/expand-home";
|
|
12
|
+
|
|
13
|
+
const FAKE_HOME = "/home/testuser";
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
mockHomedir.mockClear();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("expandHomePath", () => {
|
|
20
|
+
describe("~ expansion", () => {
|
|
21
|
+
test("bare ~ expands to homedir()", () => {
|
|
22
|
+
expect(expandHomePath("~")).toBe(FAKE_HOME);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("~/path expands to homedir()/path", () => {
|
|
26
|
+
expect(expandHomePath("~/dev/project")).toBe(
|
|
27
|
+
join(FAKE_HOME, "dev/project"),
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("~/path/* expands to homedir()/path/*", () => {
|
|
32
|
+
expect(expandHomePath("~/dev/*")).toBe(join(FAKE_HOME, "dev/*"));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("~\\ (Windows separator) expands to homedir() + rest", () => {
|
|
36
|
+
expect(expandHomePath("~\\dev\\project")).toBe(
|
|
37
|
+
join(FAKE_HOME, "dev\\project"),
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("~username (no separator) is not expanded (no-op)", () => {
|
|
42
|
+
expect(expandHomePath("~username")).toBe("~username");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("$HOME expansion", () => {
|
|
47
|
+
test("bare $HOME expands to homedir()", () => {
|
|
48
|
+
expect(expandHomePath("$HOME")).toBe(FAKE_HOME);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("$HOME/path expands to homedir()/path", () => {
|
|
52
|
+
expect(expandHomePath("$HOME/dev/project")).toBe(
|
|
53
|
+
join(FAKE_HOME, "dev/project"),
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("$HOME/path/* expands to homedir()/path/*", () => {
|
|
58
|
+
expect(expandHomePath("$HOME/dev/*")).toBe(join(FAKE_HOME, "dev/*"));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("$HOME\\ (Windows separator) expands to homedir() + rest", () => {
|
|
62
|
+
expect(expandHomePath("$HOME\\dev\\project")).toBe(
|
|
63
|
+
join(FAKE_HOME, "dev\\project"),
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("$HOMEDIR (no separator) is not expanded (no-op)", () => {
|
|
68
|
+
expect(expandHomePath("$HOMEDIR")).toBe("$HOMEDIR");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("no-op patterns", () => {
|
|
73
|
+
test("absolute path is unchanged", () => {
|
|
74
|
+
expect(expandHomePath("/usr/local/bin")).toBe("/usr/local/bin");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("relative path is unchanged", () => {
|
|
78
|
+
expect(expandHomePath("dev/project")).toBe("dev/project");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("glob-only pattern is unchanged", () => {
|
|
82
|
+
expect(expandHomePath("*")).toBe("*");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("empty string is unchanged", () => {
|
|
86
|
+
expect(expandHomePath("")).toBe("");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("bash command pattern starting with a word is unchanged", () => {
|
|
90
|
+
expect(expandHomePath("git push *")).toBe("git push *");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -64,6 +64,7 @@ function makeRuntime(
|
|
|
64
64
|
subagentSessionsDir: "/test/agent/subagent-sessions",
|
|
65
65
|
forwardingDir: "/test/agent/sessions/permission-forwarding",
|
|
66
66
|
globalLogsDir: "/test/agent/extensions/pi-permission-system/logs",
|
|
67
|
+
piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
|
|
67
68
|
config: { debugLog: false, permissionReviewLog: true, yoloMode: false },
|
|
68
69
|
runtimeContext: null,
|
|
69
70
|
permissionManager: {
|
|
@@ -395,6 +396,152 @@ describe("handleToolCall — external-directory gate", () => {
|
|
|
395
396
|
});
|
|
396
397
|
});
|
|
397
398
|
|
|
399
|
+
// ── Pi infrastructure read bypass ───────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
describe("handleToolCall — Pi infrastructure read bypass", () => {
|
|
402
|
+
const infraPath = "/test/agent/git/some-package/SKILL.md";
|
|
403
|
+
|
|
404
|
+
it("skips external-directory gate for read tool targeting an infra dir", async () => {
|
|
405
|
+
const deps = makeDeps({
|
|
406
|
+
runtime: makeRuntime({
|
|
407
|
+
permissionManager: {
|
|
408
|
+
checkPermission: vi
|
|
409
|
+
.fn()
|
|
410
|
+
.mockReturnValue(makePermissionResult("allow")),
|
|
411
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
412
|
+
}),
|
|
413
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
414
|
+
});
|
|
415
|
+
const event = {
|
|
416
|
+
type: "tool_call",
|
|
417
|
+
toolCallId: "tc-infra-read",
|
|
418
|
+
name: "read",
|
|
419
|
+
input: { path: infraPath },
|
|
420
|
+
};
|
|
421
|
+
const result = await handleToolCall(deps, event, makeCtx());
|
|
422
|
+
expect(result).toEqual({});
|
|
423
|
+
// external_directory permission check must NOT have been called.
|
|
424
|
+
const checkPermission = deps.runtime.permissionManager
|
|
425
|
+
.checkPermission as ReturnType<typeof vi.fn>;
|
|
426
|
+
const calls = checkPermission.mock.calls as Array<[string, ...unknown[]]>;
|
|
427
|
+
const extDirCalls = calls.filter(
|
|
428
|
+
([surface]) => surface === "external_directory",
|
|
429
|
+
);
|
|
430
|
+
expect(extDirCalls).toHaveLength(0);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("does NOT skip gate for write tool targeting an infra dir", async () => {
|
|
434
|
+
const deps = makeDeps({
|
|
435
|
+
runtime: makeRuntime({
|
|
436
|
+
permissionManager: {
|
|
437
|
+
checkPermission: vi
|
|
438
|
+
.fn()
|
|
439
|
+
.mockReturnValue(makePermissionResult("deny")),
|
|
440
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
441
|
+
}),
|
|
442
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "write" }]),
|
|
443
|
+
});
|
|
444
|
+
const event = {
|
|
445
|
+
type: "tool_call",
|
|
446
|
+
toolCallId: "tc-infra-write",
|
|
447
|
+
name: "write",
|
|
448
|
+
input: { path: infraPath },
|
|
449
|
+
};
|
|
450
|
+
const result = await handleToolCall(deps, event, makeCtx());
|
|
451
|
+
expect(result).toMatchObject({ block: true });
|
|
452
|
+
const checkPermission = deps.runtime.permissionManager
|
|
453
|
+
.checkPermission as ReturnType<typeof vi.fn>;
|
|
454
|
+
const calls = checkPermission.mock.calls as Array<[string, ...unknown[]]>;
|
|
455
|
+
const extDirCalls = calls.filter(
|
|
456
|
+
([surface]) => surface === "external_directory",
|
|
457
|
+
);
|
|
458
|
+
expect(extDirCalls.length).toBeGreaterThan(0);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("does NOT skip gate for read tool targeting a non-infra external path", async () => {
|
|
462
|
+
const deps = makeDeps({
|
|
463
|
+
runtime: makeRuntime({
|
|
464
|
+
permissionManager: {
|
|
465
|
+
checkPermission: vi
|
|
466
|
+
.fn()
|
|
467
|
+
.mockReturnValue(makePermissionResult("deny")),
|
|
468
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
469
|
+
}),
|
|
470
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
471
|
+
});
|
|
472
|
+
const event = {
|
|
473
|
+
type: "tool_call",
|
|
474
|
+
toolCallId: "tc-non-infra",
|
|
475
|
+
name: "read",
|
|
476
|
+
input: { path: "/etc/passwd" },
|
|
477
|
+
};
|
|
478
|
+
const result = await handleToolCall(deps, event, makeCtx());
|
|
479
|
+
expect(result).toMatchObject({ block: true });
|
|
480
|
+
const checkPermission = deps.runtime.permissionManager
|
|
481
|
+
.checkPermission as ReturnType<typeof vi.fn>;
|
|
482
|
+
const calls = checkPermission.mock.calls as Array<[string, ...unknown[]]>;
|
|
483
|
+
const extDirCalls = calls.filter(
|
|
484
|
+
([surface]) => surface === "external_directory",
|
|
485
|
+
);
|
|
486
|
+
expect(extDirCalls.length).toBeGreaterThan(0);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("writes a review log entry when bypassing the gate", async () => {
|
|
490
|
+
const writeReviewLog = vi.fn();
|
|
491
|
+
const deps = makeDeps({
|
|
492
|
+
runtime: makeRuntime({ writeReviewLog }),
|
|
493
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
494
|
+
});
|
|
495
|
+
const event = {
|
|
496
|
+
type: "tool_call",
|
|
497
|
+
toolCallId: "tc-infra-log",
|
|
498
|
+
name: "read",
|
|
499
|
+
input: { path: infraPath },
|
|
500
|
+
};
|
|
501
|
+
await handleToolCall(deps, event, makeCtx());
|
|
502
|
+
expect(writeReviewLog).toHaveBeenCalledWith(
|
|
503
|
+
"permission_request.infrastructure_auto_allowed",
|
|
504
|
+
expect.objectContaining({ toolName: "read", path: infraPath }),
|
|
505
|
+
);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("respects config piInfrastructureReadPaths for bypass", async () => {
|
|
509
|
+
const customInfraPath = "/custom/infra/packages/SKILL.md";
|
|
510
|
+
const deps = makeDeps({
|
|
511
|
+
runtime: makeRuntime({
|
|
512
|
+
piInfrastructureDirs: [],
|
|
513
|
+
config: {
|
|
514
|
+
debugLog: false,
|
|
515
|
+
permissionReviewLog: true,
|
|
516
|
+
yoloMode: false,
|
|
517
|
+
piInfrastructureReadPaths: ["/custom/infra/packages"],
|
|
518
|
+
},
|
|
519
|
+
permissionManager: {
|
|
520
|
+
checkPermission: vi
|
|
521
|
+
.fn()
|
|
522
|
+
.mockReturnValue(makePermissionResult("allow")),
|
|
523
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
524
|
+
}),
|
|
525
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
526
|
+
});
|
|
527
|
+
const event = {
|
|
528
|
+
type: "tool_call",
|
|
529
|
+
toolCallId: "tc-config-infra",
|
|
530
|
+
name: "read",
|
|
531
|
+
input: { path: customInfraPath },
|
|
532
|
+
};
|
|
533
|
+
const result = await handleToolCall(deps, event, makeCtx());
|
|
534
|
+
expect(result).toEqual({});
|
|
535
|
+
const checkPermission = deps.runtime.permissionManager
|
|
536
|
+
.checkPermission as ReturnType<typeof vi.fn>;
|
|
537
|
+
const calls = checkPermission.mock.calls as Array<[string, ...unknown[]]>;
|
|
538
|
+
const extDirCalls = calls.filter(
|
|
539
|
+
([surface]) => surface === "external_directory",
|
|
540
|
+
);
|
|
541
|
+
expect(extDirCalls).toHaveLength(0);
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
398
545
|
// ── bash external-directory gate ──────────────────────────────────────────
|
|
399
546
|
|
|
400
547
|
describe("handleToolCall — bash external-directory gate", () => {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Step 6: all five surfaces produce identical decisions to the old branching code.
|
|
6
6
|
*/
|
|
7
7
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
-
import { tmpdir } from "node:os";
|
|
8
|
+
import { homedir, tmpdir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { describe, expect, it } from "vitest";
|
|
11
11
|
import { PermissionManager } from "../src/permission-manager";
|
|
@@ -373,3 +373,76 @@ describe("checkPermission — source derivation and matchedPattern", () => {
|
|
|
373
373
|
});
|
|
374
374
|
});
|
|
375
375
|
});
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// Home directory expansion in external_directory patterns
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
describe("checkPermission — home path expansion in external_directory rules", () => {
|
|
382
|
+
it("~/glob pattern allows a path under the real home directory", () => {
|
|
383
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
384
|
+
"*": "ask",
|
|
385
|
+
external_directory: { "~/trusted/*": "allow" },
|
|
386
|
+
});
|
|
387
|
+
try {
|
|
388
|
+
const result = manager.checkPermission("external_directory", {
|
|
389
|
+
path: join(homedir(), "trusted/repo"),
|
|
390
|
+
});
|
|
391
|
+
expect(result.state).toBe("allow");
|
|
392
|
+
expect(result.source).toBe("special");
|
|
393
|
+
expect(result.matchedPattern).toBe("~/trusted/*");
|
|
394
|
+
} finally {
|
|
395
|
+
cleanup();
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("$HOME/glob pattern allows a path under the real home directory", () => {
|
|
400
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
401
|
+
"*": "ask",
|
|
402
|
+
external_directory: { "$HOME/trusted/*": "allow" },
|
|
403
|
+
});
|
|
404
|
+
try {
|
|
405
|
+
const result = manager.checkPermission("external_directory", {
|
|
406
|
+
path: join(homedir(), "trusted/repo"),
|
|
407
|
+
});
|
|
408
|
+
expect(result.state).toBe("allow");
|
|
409
|
+
expect(result.source).toBe("special");
|
|
410
|
+
expect(result.matchedPattern).toBe("$HOME/trusted/*");
|
|
411
|
+
} finally {
|
|
412
|
+
cleanup();
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("~/glob deny rule blocks a path under home", () => {
|
|
417
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
418
|
+
"*": "allow",
|
|
419
|
+
external_directory: { "~/private/*": "deny" },
|
|
420
|
+
});
|
|
421
|
+
try {
|
|
422
|
+
const result = manager.checkPermission("external_directory", {
|
|
423
|
+
path: join(homedir(), "private/secrets.txt"),
|
|
424
|
+
});
|
|
425
|
+
expect(result.state).toBe("deny");
|
|
426
|
+
expect(result.matchedPattern).toBe("~/private/*");
|
|
427
|
+
} finally {
|
|
428
|
+
cleanup();
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("~/glob pattern does not match a path outside home", () => {
|
|
433
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
434
|
+
"*": "ask",
|
|
435
|
+
external_directory: { "~/trusted/*": "allow" },
|
|
436
|
+
});
|
|
437
|
+
try {
|
|
438
|
+
const result = manager.checkPermission("external_directory", {
|
|
439
|
+
path: "/tmp/not-home/file",
|
|
440
|
+
});
|
|
441
|
+
// Falls back to the "*": "ask" default — no allow from the ~/trusted/* rule.
|
|
442
|
+
expect(result.state).toBe("ask");
|
|
443
|
+
expect(result.matchedPattern).toBeUndefined();
|
|
444
|
+
} finally {
|
|
445
|
+
cleanup();
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
discoverGlobalNodeModulesRoot,
|
|
6
|
+
isPiInfrastructureRead,
|
|
7
|
+
} from "../src/external-directory";
|
|
8
|
+
|
|
9
|
+
// ── discoverGlobalNodeModulesRoot ──────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
describe("discoverGlobalNodeModulesRoot", () => {
|
|
12
|
+
test("returns the node_modules dir when the file is inside one", () => {
|
|
13
|
+
const url =
|
|
14
|
+
"file:///opt/homebrew/lib/node_modules/pi-permission-system/dist/external-directory.js";
|
|
15
|
+
expect(discoverGlobalNodeModulesRoot(url)).toBe(
|
|
16
|
+
"/opt/homebrew/lib/node_modules",
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("returns node_modules for a deeply nested file", () => {
|
|
21
|
+
const url =
|
|
22
|
+
"file:///home/user/.nvm/versions/node/v20/lib/node_modules/pi-permission-system/src/external-directory.js";
|
|
23
|
+
expect(discoverGlobalNodeModulesRoot(url)).toBe(
|
|
24
|
+
"/home/user/.nvm/versions/node/v20/lib/node_modules",
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("returns node_modules for a bun global install path", () => {
|
|
29
|
+
const url =
|
|
30
|
+
"file:///home/user/.bun/install/global/node_modules/pi-permission-system/dist/external-directory.js";
|
|
31
|
+
expect(discoverGlobalNodeModulesRoot(url)).toBe(
|
|
32
|
+
"/home/user/.bun/install/global/node_modules",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("returns the innermost (closest-to-file) node_modules ancestor", () => {
|
|
37
|
+
// The walk-up algorithm stops at the first node_modules dir it encounters,
|
|
38
|
+
// which is the innermost one when the file is inside a nested install.
|
|
39
|
+
// In practice this never happens for a real global install — the extension
|
|
40
|
+
// is always directly at <global_root>/node_modules/pi-permission-system/…
|
|
41
|
+
const url =
|
|
42
|
+
"file:///opt/lib/node_modules/some-pkg/node_modules/pi-permission-system/dist/index.js";
|
|
43
|
+
expect(discoverGlobalNodeModulesRoot(url)).toBe(
|
|
44
|
+
"/opt/lib/node_modules/some-pkg/node_modules",
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("returns null when the file is not inside any node_modules directory", () => {
|
|
49
|
+
const url =
|
|
50
|
+
"file:///home/user/development/pi-permission-system/dist/external-directory.js";
|
|
51
|
+
expect(discoverGlobalNodeModulesRoot(url)).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("returns null for a root-level file", () => {
|
|
55
|
+
const url = "file:///external-directory.js";
|
|
56
|
+
expect(discoverGlobalNodeModulesRoot(url)).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("returns null for an invalid URL", () => {
|
|
60
|
+
expect(discoverGlobalNodeModulesRoot("not-a-url")).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("works with the real import.meta.url of this extension (smoke test)", () => {
|
|
64
|
+
// The extension IS installed inside a node_modules tree when running in CI
|
|
65
|
+
// or global install. In a local dev checkout the result may be null — that's
|
|
66
|
+
// the documented graceful-degradation path.
|
|
67
|
+
const result = discoverGlobalNodeModulesRoot();
|
|
68
|
+
expect(result === null || result.endsWith("node_modules")).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("the discovered path includes the pi-permission-system package directory", () => {
|
|
72
|
+
const url =
|
|
73
|
+
"file:///opt/homebrew/lib/node_modules/pi-permission-system/dist/external-directory.js";
|
|
74
|
+
const root = discoverGlobalNodeModulesRoot(url);
|
|
75
|
+
expect(root).not.toBeNull();
|
|
76
|
+
expect(join(root!, "pi-permission-system")).toBe(
|
|
77
|
+
"/opt/homebrew/lib/node_modules/pi-permission-system",
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ── isPiInfrastructureRead ─────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
const INFRA_DIRS = [
|
|
85
|
+
"/home/user/.pi/agent",
|
|
86
|
+
"/home/user/.pi/agent/git",
|
|
87
|
+
"/opt/homebrew/lib/node_modules",
|
|
88
|
+
];
|
|
89
|
+
const CWD = "/home/user/project";
|
|
90
|
+
|
|
91
|
+
describe("isPiInfrastructureRead", () => {
|
|
92
|
+
// ── read tools allowed for infra paths ──────────────────────────────────
|
|
93
|
+
|
|
94
|
+
test("allows 'read' tool for a file inside agentDir", () => {
|
|
95
|
+
expect(
|
|
96
|
+
isPiInfrastructureRead(
|
|
97
|
+
"read",
|
|
98
|
+
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
|
|
99
|
+
INFRA_DIRS,
|
|
100
|
+
CWD,
|
|
101
|
+
),
|
|
102
|
+
).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("allows 'find' tool for a path inside node_modules infra dir", () => {
|
|
106
|
+
expect(
|
|
107
|
+
isPiInfrastructureRead(
|
|
108
|
+
"find",
|
|
109
|
+
"/opt/homebrew/lib/node_modules/pi-ask-user/skills",
|
|
110
|
+
INFRA_DIRS,
|
|
111
|
+
CWD,
|
|
112
|
+
),
|
|
113
|
+
).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("allows 'grep' tool for a path inside agentDir/git", () => {
|
|
117
|
+
expect(
|
|
118
|
+
isPiInfrastructureRead(
|
|
119
|
+
"grep",
|
|
120
|
+
"/home/user/.pi/agent/git/some-package/README.md",
|
|
121
|
+
INFRA_DIRS,
|
|
122
|
+
CWD,
|
|
123
|
+
),
|
|
124
|
+
).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("allows 'ls' tool for a path inside node_modules infra dir", () => {
|
|
128
|
+
expect(
|
|
129
|
+
isPiInfrastructureRead(
|
|
130
|
+
"ls",
|
|
131
|
+
"/opt/homebrew/lib/node_modules/pi-permission-system",
|
|
132
|
+
INFRA_DIRS,
|
|
133
|
+
CWD,
|
|
134
|
+
),
|
|
135
|
+
).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ── write tools never allowed even for infra paths ───────────────────────
|
|
139
|
+
|
|
140
|
+
test("blocks 'write' tool for a file inside agentDir", () => {
|
|
141
|
+
expect(
|
|
142
|
+
isPiInfrastructureRead(
|
|
143
|
+
"write",
|
|
144
|
+
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
|
|
145
|
+
INFRA_DIRS,
|
|
146
|
+
CWD,
|
|
147
|
+
),
|
|
148
|
+
).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("blocks 'edit' tool for a file inside node_modules", () => {
|
|
152
|
+
expect(
|
|
153
|
+
isPiInfrastructureRead(
|
|
154
|
+
"edit",
|
|
155
|
+
"/opt/homebrew/lib/node_modules/pi-ask-user/skills/ask-user/SKILL.md",
|
|
156
|
+
INFRA_DIRS,
|
|
157
|
+
CWD,
|
|
158
|
+
),
|
|
159
|
+
).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("blocks 'bash' tool regardless of path", () => {
|
|
163
|
+
expect(
|
|
164
|
+
isPiInfrastructureRead(
|
|
165
|
+
"bash",
|
|
166
|
+
"/opt/homebrew/lib/node_modules/pi-ask-user/SKILL.md",
|
|
167
|
+
INFRA_DIRS,
|
|
168
|
+
CWD,
|
|
169
|
+
),
|
|
170
|
+
).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ── non-infra paths not allowed ──────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
test("does not allow 'read' for a path outside all infra dirs", () => {
|
|
176
|
+
expect(isPiInfrastructureRead("read", "/etc/passwd", INFRA_DIRS, CWD)).toBe(
|
|
177
|
+
false,
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("does not allow 'read' for a path only partially matching an infra dir prefix", () => {
|
|
182
|
+
// /home/user/.pi/agent-other should not match /home/user/.pi/agent
|
|
183
|
+
expect(
|
|
184
|
+
isPiInfrastructureRead(
|
|
185
|
+
"read",
|
|
186
|
+
"/home/user/.pi/agent-other/config.json",
|
|
187
|
+
INFRA_DIRS,
|
|
188
|
+
CWD,
|
|
189
|
+
),
|
|
190
|
+
).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ── project-local Pi packages (.pi/npm, .pi/git) ─────────────────────────
|
|
194
|
+
|
|
195
|
+
test("allows 'read' for a path inside project-local .pi/npm/", () => {
|
|
196
|
+
expect(
|
|
197
|
+
isPiInfrastructureRead(
|
|
198
|
+
"read",
|
|
199
|
+
`${CWD}/.pi/npm/node_modules/some-skill/SKILL.md`,
|
|
200
|
+
INFRA_DIRS,
|
|
201
|
+
CWD,
|
|
202
|
+
),
|
|
203
|
+
).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("allows 'read' for a path inside project-local .pi/git/", () => {
|
|
207
|
+
expect(
|
|
208
|
+
isPiInfrastructureRead(
|
|
209
|
+
"read",
|
|
210
|
+
`${CWD}/.pi/git/github.com/org/skill-repo/SKILL.md`,
|
|
211
|
+
INFRA_DIRS,
|
|
212
|
+
CWD,
|
|
213
|
+
),
|
|
214
|
+
).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("blocks 'write' for a path inside project-local .pi/npm/", () => {
|
|
218
|
+
expect(
|
|
219
|
+
isPiInfrastructureRead(
|
|
220
|
+
"write",
|
|
221
|
+
`${CWD}/.pi/npm/node_modules/some-skill/SKILL.md`,
|
|
222
|
+
INFRA_DIRS,
|
|
223
|
+
CWD,
|
|
224
|
+
),
|
|
225
|
+
).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ── empty / edge cases ───────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
test("returns false when infrastructureDirs is empty and path is not project-local", () => {
|
|
231
|
+
expect(isPiInfrastructureRead("read", "/etc/passwd", [], CWD)).toBe(false);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("returns false when infrastructureDirs is empty but path IS project-local .pi/npm", () => {
|
|
235
|
+
// Project-local paths are checked separately from the dirs array.
|
|
236
|
+
expect(
|
|
237
|
+
isPiInfrastructureRead(
|
|
238
|
+
"read",
|
|
239
|
+
`${CWD}/.pi/npm/node_modules/x/SKILL.md`,
|
|
240
|
+
[],
|
|
241
|
+
CWD,
|
|
242
|
+
),
|
|
243
|
+
).toBe(true);
|
|
244
|
+
});
|
|
245
|
+
});
|