@gotgenes/pi-permission-system 5.1.0 → 5.1.2
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 +24 -0
- package/README.md +6 -7
- package/package.json +1 -1
- package/schemas/permissions.schema.json +1 -1
- package/src/external-directory.ts +56 -10
- package/tests/external-directory.test.ts +107 -1
- package/tests/permission-system.test.ts +183 -1
- package/tests/pi-infrastructure-read.test.ts +21 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [5.1.2](https://github.com/gotgenes/pi-permission-system/compare/v5.1.1...v5.1.2) (2026-05-05)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Documentation
|
|
12
|
+
|
|
13
|
+
* fix README per-agent frontmatter example to flat format ([#78](https://github.com/gotgenes/pi-permission-system/issues/78)) ([1295427](https://github.com/gotgenes/pi-permission-system/commit/129542795218a6ada1f8d069a22b5ace5ec6c445))
|
|
14
|
+
* plan fix README frontmatter example and add missing tests ([#78](https://github.com/gotgenes/pi-permission-system/issues/78)) ([3fc99e1](https://github.com/gotgenes/pi-permission-system/commit/3fc99e1b3adf8193c94ac778617ca830488fa621))
|
|
15
|
+
* **retro:** add retro notes for issue [#93](https://github.com/gotgenes/pi-permission-system/issues/93) ([c9e8e89](https://github.com/gotgenes/pi-permission-system/commit/c9e8e89eb4057866198402add374d72a90a2fa2e))
|
|
16
|
+
|
|
17
|
+
## [5.1.1](https://github.com/gotgenes/pi-permission-system/compare/v5.1.0...v5.1.1) (2026-05-05)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
* discover global node_modules root from dev checkout via npm root -g fallback ([93aac81](https://github.com/gotgenes/pi-permission-system/commit/93aac81bd830ec260d2156b34ca8074f6c533255))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Documentation
|
|
26
|
+
|
|
27
|
+
* note npm root -g fallback for dev checkout infrastructure reads ([d06caf7](https://github.com/gotgenes/pi-permission-system/commit/d06caf73371c7ea73f1c56a5efb86b2023292dd3))
|
|
28
|
+
* plan createRequire fallback for dev checkout infra read bypass ([#93](https://github.com/gotgenes/pi-permission-system/issues/93)) ([7750044](https://github.com/gotgenes/pi-permission-system/commit/775004477efff0d3cd2eb5ba7a0fcbdd98f3d122))
|
|
29
|
+
* plan npm root -g fallback for dev checkout infra read bypass ([#93](https://github.com/gotgenes/pi-permission-system/issues/93)) ([85e697c](https://github.com/gotgenes/pi-permission-system/commit/85e697c5062a36fd150bd4d6b377ca906b3a1dbf))
|
|
30
|
+
* **retro:** add retro notes for issue [#91](https://github.com/gotgenes/pi-permission-system/issues/91) ([d2d1263](https://github.com/gotgenes/pi-permission-system/commit/d2d1263955741053b2cf8718830d88043b9cdd8e))
|
|
31
|
+
|
|
8
32
|
## [5.1.0](https://github.com/gotgenes/pi-permission-system/compare/v5.0.0...v5.1.0) (2026-05-05)
|
|
9
33
|
|
|
10
34
|
|
package/README.md
CHANGED
|
@@ -170,22 +170,21 @@ Override global permissions for specific agents via YAML frontmatter in the glob
|
|
|
170
170
|
---
|
|
171
171
|
name: my-agent
|
|
172
172
|
permission:
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
mcp: allow
|
|
173
|
+
read: allow
|
|
174
|
+
write: deny
|
|
175
|
+
mcp: allow
|
|
177
176
|
bash:
|
|
178
177
|
git status: allow
|
|
179
178
|
git *: ask
|
|
180
179
|
mcp:
|
|
181
180
|
chrome_devtools_*: deny
|
|
182
181
|
exa_*: allow
|
|
183
|
-
|
|
182
|
+
skill:
|
|
184
183
|
"*": ask
|
|
185
184
|
---
|
|
186
185
|
```
|
|
187
186
|
|
|
188
|
-
**MCP behavior:** `permission.
|
|
187
|
+
**MCP behavior:** `permission.mcp` is the coarse entry/fallback permission for a registered `mcp` tool when one is available. More specific `permission.mcp` target rules override that fallback when they match.
|
|
189
188
|
|
|
190
189
|
**Limitations:** The frontmatter parser is intentionally minimal. Use only `key: value` scalars and nested maps. Avoid arrays, multi-line scalars, and YAML anchors.
|
|
191
190
|
|
|
@@ -379,7 +378,7 @@ Infrastructure directories include:
|
|
|
379
378
|
|
|
380
379
|
1. The agent config directory (`~/.pi/agent/` or `$PI_CODING_AGENT_DIR`)
|
|
381
380
|
2. Git-cloned global packages (`<agentDir>/git/`)
|
|
382
|
-
3. The global `node_modules` root (auto-discovered from the extension's own install path
|
|
381
|
+
3. The global `node_modules` root (auto-discovered from the extension's own install path; falls back to `npm root -g` when running from a local development checkout)
|
|
383
382
|
4. Project-local Pi packages (`<cwd>/.pi/npm/` and `<cwd>/.pi/git/`)
|
|
384
383
|
5. Any paths listed in `piInfrastructureReadPaths`
|
|
385
384
|
|
package/package.json
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"piInfrastructureReadPaths": {
|
|
33
33
|
"description": "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the external_directory gate. Supports ~ expansion. Directory prefixes only (no globs).",
|
|
34
|
-
"markdownDescription": "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the `external_directory` gate.\n\nThe extension auto-discovers the global node_modules root, `agentDir`, `agentDir/git`, and project-local `.pi/npm/` and `.pi/git/`. Add entries here for edge cases where auto-discovery is insufficient.\n\nSupports `~` expansion. Directory prefixes only — no glob patterns.",
|
|
34
|
+
"markdownDescription": "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the `external_directory` gate.\n\nThe extension auto-discovers the global node_modules root (walks up from the extension's install path; falls back to `npm root -g` from a dev checkout), `agentDir`, `agentDir/git`, and project-local `.pi/npm/` and `.pi/git/`. Add entries here for edge cases where auto-discovery is insufficient (e.g. custom `npmCommand` pointing to pnpm).\n\nSupports `~` expansion. Directory prefixes only — no glob patterns.",
|
|
35
35
|
"type": "array",
|
|
36
36
|
"items": {
|
|
37
37
|
"type": "string",
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
1
3
|
import { createRequire } from "node:module";
|
|
2
4
|
import { homedir } from "node:os";
|
|
3
5
|
import { basename, dirname, join, normalize, resolve, sep } from "node:path";
|
|
@@ -6,21 +8,16 @@ import { fileURLToPath } from "node:url";
|
|
|
6
8
|
import { getNonEmptyString, toRecord } from "./common";
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
+
* Walk up the directory tree from the given file URL until a directory
|
|
12
|
+
* literally named `node_modules` is found.
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* Returns `null` when the file is not inside any node_modules tree, or when
|
|
15
|
-
* the URL cannot be parsed — callers must degrade gracefully.
|
|
14
|
+
* Returns the `node_modules` path, or `null` if the URL cannot be parsed or
|
|
15
|
+
* no `node_modules` ancestor exists.
|
|
16
16
|
*/
|
|
17
|
-
|
|
18
|
-
fromUrl = import.meta.url,
|
|
19
|
-
): string | null {
|
|
17
|
+
function walkUpToNodeModules(fromUrl: string): string | null {
|
|
20
18
|
try {
|
|
21
19
|
const thisFile = fileURLToPath(fromUrl);
|
|
22
20
|
let dir = dirname(thisFile);
|
|
23
|
-
// Walk up until we find a directory named "node_modules" or hit the root.
|
|
24
21
|
while (dir !== dirname(dir)) {
|
|
25
22
|
if (basename(dir) === "node_modules") {
|
|
26
23
|
return dir;
|
|
@@ -33,6 +30,55 @@ export function discoverGlobalNodeModulesRoot(
|
|
|
33
30
|
}
|
|
34
31
|
}
|
|
35
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Run `npm root -g` synchronously and return the trimmed output, or `null` on
|
|
35
|
+
* any failure (non-zero exit, ENOENT, timeout, non-existent path).
|
|
36
|
+
*
|
|
37
|
+
* Only called when the walk-up-from-self strategy fails (i.e. the extension is
|
|
38
|
+
* running from a local development checkout, not a global install).
|
|
39
|
+
*/
|
|
40
|
+
function discoverGlobalNodeModulesViaSubprocess(): string | null {
|
|
41
|
+
try {
|
|
42
|
+
const result = spawnSync("npm", ["root", "-g"], {
|
|
43
|
+
encoding: "utf-8",
|
|
44
|
+
timeout: 5000,
|
|
45
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
46
|
+
});
|
|
47
|
+
const root = result.stdout?.trim();
|
|
48
|
+
if (result.status === 0 && root && existsSync(root)) {
|
|
49
|
+
return root;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Discover the global node_modules root.
|
|
59
|
+
*
|
|
60
|
+
* Strategy 1 (zero-cost, covers all global installs): walk up from
|
|
61
|
+
* `fromUrl` (defaults to this module's own `import.meta.url`) looking for a
|
|
62
|
+
* directory named `node_modules`. This works whenever the extension is
|
|
63
|
+
* installed inside a `node_modules` tree.
|
|
64
|
+
*
|
|
65
|
+
* Strategy 2 (subprocess fallback, dev checkout only): when Strategy 1 fails
|
|
66
|
+
* because the extension is running from a local development checkout with no
|
|
67
|
+
* `node_modules` ancestor, run `npm root -g` to discover the global root.
|
|
68
|
+
* Pi installs skills and extensions via `npm` by default, so `npm root -g`
|
|
69
|
+
* returns the correct root regardless of the user's own project package
|
|
70
|
+
* manager.
|
|
71
|
+
*
|
|
72
|
+
* Returns `null` when both strategies fail — callers must degrade gracefully.
|
|
73
|
+
*/
|
|
74
|
+
export function discoverGlobalNodeModulesRoot(
|
|
75
|
+
fromUrl = import.meta.url,
|
|
76
|
+
): string | null {
|
|
77
|
+
const fromSelf = walkUpToNodeModules(fromUrl);
|
|
78
|
+
if (fromSelf) return fromSelf;
|
|
79
|
+
return discoverGlobalNodeModulesViaSubprocess();
|
|
80
|
+
}
|
|
81
|
+
|
|
36
82
|
/**
|
|
37
83
|
* Paths that are universally safe and should never trigger external-directory checks.
|
|
38
84
|
* These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
// Hoisted stubs for mocks that reference them in vi.mock factories.
|
|
5
|
+
const { mockSpawnSync, mockExistsSync } = vi.hoisted(() => ({
|
|
6
|
+
mockSpawnSync: vi.fn(),
|
|
7
|
+
mockExistsSync: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
// Mock node:child_process so tests don't spawn real subprocesses.
|
|
11
|
+
vi.mock("node:child_process", () => ({
|
|
12
|
+
spawnSync: mockSpawnSync,
|
|
13
|
+
default: { spawnSync: mockSpawnSync },
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
// Mock node:fs so existsSync is controllable.
|
|
17
|
+
vi.mock("node:fs", () => ({
|
|
18
|
+
existsSync: mockExistsSync,
|
|
19
|
+
default: { existsSync: mockExistsSync },
|
|
20
|
+
}));
|
|
3
21
|
|
|
4
22
|
// Mock node:os so tilde-expansion is deterministic across platforms.
|
|
5
23
|
vi.mock("node:os", () => {
|
|
@@ -11,6 +29,7 @@ vi.mock("node:os", () => {
|
|
|
11
29
|
});
|
|
12
30
|
|
|
13
31
|
import {
|
|
32
|
+
discoverGlobalNodeModulesRoot,
|
|
14
33
|
formatExternalDirectoryAskPrompt,
|
|
15
34
|
formatExternalDirectoryDenyReason,
|
|
16
35
|
formatExternalDirectoryHardStopHint,
|
|
@@ -317,3 +336,90 @@ describe("formatExternalDirectoryUserDeniedReason", () => {
|
|
|
317
336
|
expect(result).not.toContain("Reason:");
|
|
318
337
|
});
|
|
319
338
|
});
|
|
339
|
+
|
|
340
|
+
describe("discoverGlobalNodeModulesRoot", () => {
|
|
341
|
+
// The walk-up-from-self strategy uses import.meta.url which resolves to a
|
|
342
|
+
// path inside the source tree during tests — there is no node_modules
|
|
343
|
+
// ancestor. So the fallback path is exercised naturally here.
|
|
344
|
+
//
|
|
345
|
+
// For the "walk-up succeeds" case, we verify the subprocess is NOT called
|
|
346
|
+
// by confirming spawnSync call count stays at zero when the URL has a
|
|
347
|
+
// node_modules ancestor.
|
|
348
|
+
|
|
349
|
+
beforeEach(() => {
|
|
350
|
+
mockSpawnSync.mockReset();
|
|
351
|
+
mockExistsSync.mockReset();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("returns node_modules root when URL is inside a node_modules tree", () => {
|
|
355
|
+
// Simulate a URL whose file path contains a node_modules ancestor.
|
|
356
|
+
const fakeUrl =
|
|
357
|
+
"file:///opt/homebrew/lib/node_modules/@gotgenes/pi-permission-system/dist/external-directory.js";
|
|
358
|
+
const result = discoverGlobalNodeModulesRoot(fakeUrl);
|
|
359
|
+
expect(result).toBe("/opt/homebrew/lib/node_modules");
|
|
360
|
+
// Subprocess should NOT have been invoked — walk-up succeeds.
|
|
361
|
+
expect(mockSpawnSync).not.toHaveBeenCalled();
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("calls npm root -g as fallback when walk-up finds no node_modules ancestor", () => {
|
|
365
|
+
const npmRootPath = "/opt/homebrew/lib/node_modules";
|
|
366
|
+
mockSpawnSync.mockReturnValue({
|
|
367
|
+
status: 0,
|
|
368
|
+
stdout: `${npmRootPath}\n`,
|
|
369
|
+
});
|
|
370
|
+
mockExistsSync.mockReturnValue(true);
|
|
371
|
+
|
|
372
|
+
// Use a file URL with no node_modules ancestor.
|
|
373
|
+
const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
|
|
374
|
+
const result = discoverGlobalNodeModulesRoot(fakeUrl);
|
|
375
|
+
|
|
376
|
+
expect(mockSpawnSync).toHaveBeenCalledWith(
|
|
377
|
+
"npm",
|
|
378
|
+
["root", "-g"],
|
|
379
|
+
expect.objectContaining({ encoding: "utf-8" }),
|
|
380
|
+
);
|
|
381
|
+
expect(result).toBe(npmRootPath);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("returns null when walk-up fails and npm root -g returns non-zero exit", () => {
|
|
385
|
+
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
|
|
386
|
+
|
|
387
|
+
const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
|
|
388
|
+
const result = discoverGlobalNodeModulesRoot(fakeUrl);
|
|
389
|
+
|
|
390
|
+
expect(result).toBeNull();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("returns null when walk-up fails and spawnSync throws", () => {
|
|
394
|
+
mockSpawnSync.mockImplementation(() => {
|
|
395
|
+
throw new Error("ENOENT");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
|
|
399
|
+
const result = discoverGlobalNodeModulesRoot(fakeUrl);
|
|
400
|
+
|
|
401
|
+
expect(result).toBeNull();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("returns null when walk-up fails and npm root -g returns non-existent path", () => {
|
|
405
|
+
mockSpawnSync.mockReturnValue({
|
|
406
|
+
status: 0,
|
|
407
|
+
stdout: "/some/nonexistent/node_modules\n",
|
|
408
|
+
});
|
|
409
|
+
mockExistsSync.mockReturnValue(false);
|
|
410
|
+
|
|
411
|
+
const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
|
|
412
|
+
const result = discoverGlobalNodeModulesRoot(fakeUrl);
|
|
413
|
+
|
|
414
|
+
expect(result).toBeNull();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("returns null when walk-up fails and npm root -g returns empty stdout", () => {
|
|
418
|
+
mockSpawnSync.mockReturnValue({ status: 0, stdout: " " });
|
|
419
|
+
|
|
420
|
+
const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
|
|
421
|
+
const result = discoverGlobalNodeModulesRoot(fakeUrl);
|
|
422
|
+
|
|
423
|
+
expect(result).toBeNull();
|
|
424
|
+
});
|
|
425
|
+
});
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
rmSync,
|
|
8
8
|
writeFileSync,
|
|
9
9
|
} from "node:fs";
|
|
10
|
-
import { tmpdir } from "node:os";
|
|
10
|
+
import { homedir, tmpdir } from "node:os";
|
|
11
11
|
import { dirname, join, resolve } from "node:path";
|
|
12
12
|
import { test } from "vitest";
|
|
13
13
|
|
|
@@ -1831,6 +1831,188 @@ test("external_directory permission is not affected by unrelated surface keys",
|
|
|
1831
1831
|
}
|
|
1832
1832
|
});
|
|
1833
1833
|
|
|
1834
|
+
test("skill pattern map in agent frontmatter overrides global skill policy", () => {
|
|
1835
|
+
const { manager, cleanup } = createManager(
|
|
1836
|
+
{
|
|
1837
|
+
permission: { "*": "deny", skill: "deny" },
|
|
1838
|
+
},
|
|
1839
|
+
{
|
|
1840
|
+
reviewer: `---
|
|
1841
|
+
name: reviewer
|
|
1842
|
+
permission:
|
|
1843
|
+
skill:
|
|
1844
|
+
"*": ask
|
|
1845
|
+
"pi-*": allow
|
|
1846
|
+
---
|
|
1847
|
+
`,
|
|
1848
|
+
},
|
|
1849
|
+
);
|
|
1850
|
+
|
|
1851
|
+
try {
|
|
1852
|
+
// Matches agent frontmatter pi-* pattern
|
|
1853
|
+
const allowed = manager.checkPermission(
|
|
1854
|
+
"skill",
|
|
1855
|
+
{ name: "pi-code-review" },
|
|
1856
|
+
"reviewer",
|
|
1857
|
+
);
|
|
1858
|
+
assert.equal(allowed.state, "allow");
|
|
1859
|
+
assert.equal(allowed.matchedPattern, "pi-*");
|
|
1860
|
+
assert.equal(allowed.source, "skill");
|
|
1861
|
+
|
|
1862
|
+
// Falls through to agent frontmatter catch-all
|
|
1863
|
+
const asked = manager.checkPermission(
|
|
1864
|
+
"skill",
|
|
1865
|
+
{ name: "other-skill" },
|
|
1866
|
+
"reviewer",
|
|
1867
|
+
);
|
|
1868
|
+
assert.equal(asked.state, "ask");
|
|
1869
|
+
assert.equal(asked.matchedPattern, "*");
|
|
1870
|
+
|
|
1871
|
+
// No agent override — global deny applies
|
|
1872
|
+
const denied = manager.checkPermission("skill", { name: "pi-code-review" });
|
|
1873
|
+
assert.equal(denied.state, "deny");
|
|
1874
|
+
assert.equal(denied.source, "skill");
|
|
1875
|
+
} finally {
|
|
1876
|
+
cleanup();
|
|
1877
|
+
}
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
test("external_directory pattern map in agent frontmatter overrides global policy", () => {
|
|
1881
|
+
const { manager, cleanup } = createManager(
|
|
1882
|
+
{
|
|
1883
|
+
permission: { "*": "allow", external_directory: "deny" },
|
|
1884
|
+
},
|
|
1885
|
+
{
|
|
1886
|
+
trusted: `---
|
|
1887
|
+
name: trusted
|
|
1888
|
+
permission:
|
|
1889
|
+
external_directory:
|
|
1890
|
+
"*": deny
|
|
1891
|
+
"~/Downloads/*": allow
|
|
1892
|
+
---
|
|
1893
|
+
`,
|
|
1894
|
+
},
|
|
1895
|
+
);
|
|
1896
|
+
|
|
1897
|
+
try {
|
|
1898
|
+
// Matches agent frontmatter ~/Downloads/* pattern
|
|
1899
|
+
const allowed = manager.checkPermission(
|
|
1900
|
+
"external_directory",
|
|
1901
|
+
{ path: `${homedir()}/Downloads/file.txt` },
|
|
1902
|
+
"trusted",
|
|
1903
|
+
);
|
|
1904
|
+
assert.equal(allowed.state, "allow");
|
|
1905
|
+
assert.equal(allowed.matchedPattern, "~/Downloads/*");
|
|
1906
|
+
assert.equal(allowed.source, "special");
|
|
1907
|
+
|
|
1908
|
+
// Falls through to agent frontmatter catch-all deny
|
|
1909
|
+
const denied = manager.checkPermission(
|
|
1910
|
+
"external_directory",
|
|
1911
|
+
{ path: `${homedir()}/Documents/secret.txt` },
|
|
1912
|
+
"trusted",
|
|
1913
|
+
);
|
|
1914
|
+
assert.equal(denied.state, "deny");
|
|
1915
|
+
assert.equal(denied.matchedPattern, "*");
|
|
1916
|
+
|
|
1917
|
+
// No agent override — global deny applies
|
|
1918
|
+
const globalDenied = manager.checkPermission("external_directory", {});
|
|
1919
|
+
assert.equal(globalDenied.state, "deny");
|
|
1920
|
+
assert.equal(globalDenied.source, "special");
|
|
1921
|
+
} finally {
|
|
1922
|
+
cleanup();
|
|
1923
|
+
}
|
|
1924
|
+
});
|
|
1925
|
+
|
|
1926
|
+
test("project-agent frontmatter skill rules override global-agent frontmatter skill rules", () => {
|
|
1927
|
+
const { manager, cleanup } = createManagerWithProject(
|
|
1928
|
+
{
|
|
1929
|
+
permission: { "*": "deny" },
|
|
1930
|
+
},
|
|
1931
|
+
{
|
|
1932
|
+
analyst: `---
|
|
1933
|
+
name: analyst
|
|
1934
|
+
permission:
|
|
1935
|
+
skill:
|
|
1936
|
+
"*": ask
|
|
1937
|
+
---
|
|
1938
|
+
`,
|
|
1939
|
+
},
|
|
1940
|
+
{
|
|
1941
|
+
projectAgentFiles: {
|
|
1942
|
+
analyst: `---
|
|
1943
|
+
name: analyst
|
|
1944
|
+
permission:
|
|
1945
|
+
skill:
|
|
1946
|
+
"pi-*": allow
|
|
1947
|
+
"*": deny
|
|
1948
|
+
---
|
|
1949
|
+
`,
|
|
1950
|
+
},
|
|
1951
|
+
},
|
|
1952
|
+
);
|
|
1953
|
+
|
|
1954
|
+
try {
|
|
1955
|
+
// Project-agent pi-* wins over global-agent *: ask
|
|
1956
|
+
const allowed = manager.checkPermission(
|
|
1957
|
+
"skill",
|
|
1958
|
+
{ name: "pi-code-review" },
|
|
1959
|
+
"analyst",
|
|
1960
|
+
);
|
|
1961
|
+
assert.equal(allowed.state, "allow");
|
|
1962
|
+
assert.equal(allowed.matchedPattern, "pi-*");
|
|
1963
|
+
|
|
1964
|
+
// Project-agent *: deny wins over global-agent *: ask
|
|
1965
|
+
const denied = manager.checkPermission(
|
|
1966
|
+
"skill",
|
|
1967
|
+
{ name: "other-skill" },
|
|
1968
|
+
"analyst",
|
|
1969
|
+
);
|
|
1970
|
+
assert.equal(denied.state, "deny");
|
|
1971
|
+
assert.equal(denied.matchedPattern, "*");
|
|
1972
|
+
} finally {
|
|
1973
|
+
cleanup();
|
|
1974
|
+
}
|
|
1975
|
+
});
|
|
1976
|
+
|
|
1977
|
+
test("project-agent frontmatter external_directory rules override global-agent frontmatter rules", () => {
|
|
1978
|
+
const { manager, cleanup } = createManagerWithProject(
|
|
1979
|
+
{
|
|
1980
|
+
permission: { "*": "allow", external_directory: "deny" },
|
|
1981
|
+
},
|
|
1982
|
+
{
|
|
1983
|
+
analyst: `---
|
|
1984
|
+
name: analyst
|
|
1985
|
+
permission:
|
|
1986
|
+
external_directory: ask
|
|
1987
|
+
---
|
|
1988
|
+
`,
|
|
1989
|
+
},
|
|
1990
|
+
{
|
|
1991
|
+
projectAgentFiles: {
|
|
1992
|
+
analyst: `---
|
|
1993
|
+
name: analyst
|
|
1994
|
+
permission:
|
|
1995
|
+
external_directory: allow
|
|
1996
|
+
---
|
|
1997
|
+
`,
|
|
1998
|
+
},
|
|
1999
|
+
},
|
|
2000
|
+
);
|
|
2001
|
+
|
|
2002
|
+
try {
|
|
2003
|
+
// Project-agent allow wins over global-agent ask
|
|
2004
|
+
const result = manager.checkPermission("external_directory", {}, "analyst");
|
|
2005
|
+
assert.equal(result.state, "allow");
|
|
2006
|
+
assert.equal(result.source, "special");
|
|
2007
|
+
|
|
2008
|
+
// Without agent context, global config deny applies
|
|
2009
|
+
const globalResult = manager.checkPermission("external_directory", {});
|
|
2010
|
+
assert.equal(globalResult.state, "deny");
|
|
2011
|
+
} finally {
|
|
2012
|
+
cleanup();
|
|
2013
|
+
}
|
|
2014
|
+
});
|
|
2015
|
+
|
|
1834
2016
|
test("tool_call blocks path-bearing tools outside cwd when external_directory is denied", async () => {
|
|
1835
2017
|
const rootDir = mkdtempSync(join(tmpdir(), "pi-permission-system-boundary-"));
|
|
1836
2018
|
const cwd = join(rootDir, "repo");
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
// Hoisted stub so the vi.mock factory can reference it.
|
|
5
|
+
const { mockSpawnSync } = vi.hoisted(() => ({
|
|
6
|
+
mockSpawnSync: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
// Mock node:child_process so tests that exercise the subprocess fallback path
|
|
10
|
+
// don't actually invoke npm. Default: subprocess fails (non-zero exit), so
|
|
11
|
+
// tests focused on the walk-up strategy continue to expect null.
|
|
12
|
+
vi.mock("node:child_process", () => ({
|
|
13
|
+
spawnSync: mockSpawnSync,
|
|
14
|
+
default: { spawnSync: mockSpawnSync },
|
|
15
|
+
}));
|
|
3
16
|
|
|
4
17
|
import {
|
|
5
18
|
discoverGlobalNodeModulesRoot,
|
|
@@ -9,6 +22,13 @@ import {
|
|
|
9
22
|
// ── discoverGlobalNodeModulesRoot ──────────────────────────────────────────
|
|
10
23
|
|
|
11
24
|
describe("discoverGlobalNodeModulesRoot", () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
// Default: subprocess fails, so walk-up-focused tests see null for URLs
|
|
27
|
+
// with no node_modules ancestor.
|
|
28
|
+
mockSpawnSync.mockReset();
|
|
29
|
+
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
|
|
30
|
+
});
|
|
31
|
+
|
|
12
32
|
test("returns the node_modules dir when the file is inside one", () => {
|
|
13
33
|
const url =
|
|
14
34
|
"file:///opt/homebrew/lib/node_modules/pi-permission-system/dist/external-directory.js";
|