@gotgenes/pi-permission-system 10.5.1 → 10.5.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 +13 -0
- package/package.json +1 -1
- package/src/input-normalizer.ts +20 -8
- package/src/path-utils.ts +1 -10
- package/test/before-agent-start-cache.test.ts +89 -0
- package/test/handlers/external-directory-session-dedup.test.ts +96 -0
- package/test/handlers/gates/bash-path.test.ts +57 -0
- package/test/handlers/gates/path.test.ts +58 -0
- package/test/handlers/tool-call.test.ts +103 -0
- package/test/helpers/manager-harness.ts +61 -0
- package/test/input-normalizer.test.ts +77 -1
- package/test/logging.test.ts +51 -0
- package/test/path-utils.test.ts +10 -0
- package/test/permission-forwarding.test.ts +73 -0
- package/test/permission-manager-unified.test.ts +1577 -3
- package/test/skill-prompt-sanitizer.test.ts +130 -0
- package/test/status.test.ts +10 -0
- package/test/system-prompt-sanitizer.test.ts +68 -0
- package/test/tool-registry.test.ts +42 -0
- package/test/yolo-mode.test.ts +78 -0
- package/test/permission-system.test.ts +0 -2785
|
@@ -6,14 +6,18 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { homedir, tmpdir } from "node:os";
|
|
9
|
-
import { join } from "node:path";
|
|
10
|
-
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { describe, expect, it, test } from "vitest";
|
|
11
11
|
import { getGlobalConfigPath, getProjectConfigPath } from "#src/config-paths";
|
|
12
12
|
import {
|
|
13
13
|
PermissionManager,
|
|
14
14
|
type ScopedPermissionManager,
|
|
15
15
|
} from "#src/permission-manager";
|
|
16
16
|
import type { Rule, Ruleset } from "#src/rule";
|
|
17
|
+
import {
|
|
18
|
+
createManager,
|
|
19
|
+
createManagerWithProject,
|
|
20
|
+
} from "#test/helpers/manager-harness";
|
|
17
21
|
|
|
18
22
|
// ---------------------------------------------------------------------------
|
|
19
23
|
// Helpers
|
|
@@ -672,7 +676,7 @@ describe("checkPermission — rule origin provenance", () => {
|
|
|
672
676
|
|
|
673
677
|
import type { PolicyLoader } from "#src/permission-manager";
|
|
674
678
|
import type { ResolvedPolicyPaths } from "#src/policy-loader";
|
|
675
|
-
import type { ScopeConfig } from "#src/types";
|
|
679
|
+
import type { PermissionCheckResult, ScopeConfig } from "#src/types";
|
|
676
680
|
|
|
677
681
|
/**
|
|
678
682
|
* Minimal in-memory PolicyLoader for testing merge + evaluation logic
|
|
@@ -1354,6 +1358,100 @@ describe("cross-cutting path surface", () => {
|
|
|
1354
1358
|
});
|
|
1355
1359
|
});
|
|
1356
1360
|
|
|
1361
|
+
// ---------------------------------------------------------------------------
|
|
1362
|
+
// Home-expansion in path values (issue #350)
|
|
1363
|
+
// ---------------------------------------------------------------------------
|
|
1364
|
+
|
|
1365
|
+
describe("cross-cutting path surface — home-expanded values", () => {
|
|
1366
|
+
it("~/... path value is denied by a ~/* rule (reported footgun)", () => {
|
|
1367
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1368
|
+
path: { "*": "allow", "~/.ssh/*": "deny" },
|
|
1369
|
+
});
|
|
1370
|
+
try {
|
|
1371
|
+
const result = manager.checkPermission("path", {
|
|
1372
|
+
path: "~/.ssh/config",
|
|
1373
|
+
});
|
|
1374
|
+
expect(result.state).toBe("deny");
|
|
1375
|
+
expect(result.matchedPattern).toBe("~/.ssh/*");
|
|
1376
|
+
} finally {
|
|
1377
|
+
cleanup();
|
|
1378
|
+
}
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
it("$HOME/... path value is denied by a ~/* rule", () => {
|
|
1382
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1383
|
+
path: { "*": "allow", "~/.ssh/*": "deny" },
|
|
1384
|
+
});
|
|
1385
|
+
try {
|
|
1386
|
+
const result = manager.checkPermission("path", {
|
|
1387
|
+
path: `${homedir()}/.ssh/config`,
|
|
1388
|
+
});
|
|
1389
|
+
expect(result.state).toBe("deny");
|
|
1390
|
+
expect(result.matchedPattern).toBe("~/.ssh/*");
|
|
1391
|
+
} finally {
|
|
1392
|
+
cleanup();
|
|
1393
|
+
}
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
it("$HOME/... path value matches a $HOME/* pattern rule", () => {
|
|
1397
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1398
|
+
path: { "*": "allow", "$HOME/.ssh/*": "deny" },
|
|
1399
|
+
});
|
|
1400
|
+
try {
|
|
1401
|
+
const result = manager.checkPermission("path", {
|
|
1402
|
+
path: "$HOME/.ssh/config",
|
|
1403
|
+
});
|
|
1404
|
+
expect(result.state).toBe("deny");
|
|
1405
|
+
expect(result.matchedPattern).toBe("$HOME/.ssh/*");
|
|
1406
|
+
} finally {
|
|
1407
|
+
cleanup();
|
|
1408
|
+
}
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
it("already-absolute home path is still denied by ~/* rule", () => {
|
|
1412
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1413
|
+
path: { "*": "allow", "~/.ssh/*": "deny" },
|
|
1414
|
+
});
|
|
1415
|
+
try {
|
|
1416
|
+
const result = manager.checkPermission("path", {
|
|
1417
|
+
path: `${homedir()}/.ssh/config`,
|
|
1418
|
+
});
|
|
1419
|
+
expect(result.state).toBe("deny");
|
|
1420
|
+
} finally {
|
|
1421
|
+
cleanup();
|
|
1422
|
+
}
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
it("non-home value is unchanged — .env still matches *.env", () => {
|
|
1426
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1427
|
+
path: { "*": "allow", "*.env": "deny" },
|
|
1428
|
+
});
|
|
1429
|
+
try {
|
|
1430
|
+
const result = manager.checkPermission("path", { path: ".env" });
|
|
1431
|
+
expect(result.state).toBe("deny");
|
|
1432
|
+
expect(result.matchedPattern).toBe("*.env");
|
|
1433
|
+
} finally {
|
|
1434
|
+
cleanup();
|
|
1435
|
+
}
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
it("per-tool read surface denies ~/... path with a ~/* rule", () => {
|
|
1439
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1440
|
+
"*": "allow",
|
|
1441
|
+
read: { "*": "allow", "~/.ssh/*": "deny" },
|
|
1442
|
+
});
|
|
1443
|
+
try {
|
|
1444
|
+
const result = manager.checkPermission("read", {
|
|
1445
|
+
path: "~/.ssh/config",
|
|
1446
|
+
});
|
|
1447
|
+
expect(result.state).toBe("deny");
|
|
1448
|
+
expect(result.matchedPattern).toBe("~/.ssh/*");
|
|
1449
|
+
} finally {
|
|
1450
|
+
cleanup();
|
|
1451
|
+
}
|
|
1452
|
+
});
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1357
1455
|
// ---------------------------------------------------------------------------
|
|
1358
1456
|
// configureForCwd and agentDir construction
|
|
1359
1457
|
// ---------------------------------------------------------------------------
|
|
@@ -1507,3 +1605,1479 @@ describe("PermissionManager — configureForCwd and agentDir option", () => {
|
|
|
1507
1605
|
}
|
|
1508
1606
|
});
|
|
1509
1607
|
});
|
|
1608
|
+
|
|
1609
|
+
// ---------------------------------------------------------------------------
|
|
1610
|
+
// Project-level and per-agent config scope — moved from catch-all (#342)
|
|
1611
|
+
// ---------------------------------------------------------------------------
|
|
1612
|
+
|
|
1613
|
+
test("Project-level config overrides base bash patterns", () => {
|
|
1614
|
+
const { manager, cleanup } = createManagerWithProject(
|
|
1615
|
+
{
|
|
1616
|
+
permission: {
|
|
1617
|
+
"*": "allow",
|
|
1618
|
+
bash: { "*": "ask", "rm -rf *": "deny" },
|
|
1619
|
+
},
|
|
1620
|
+
},
|
|
1621
|
+
{},
|
|
1622
|
+
{
|
|
1623
|
+
projectConfig: {
|
|
1624
|
+
permission: { bash: { "rm -rf build": "allow" } },
|
|
1625
|
+
},
|
|
1626
|
+
},
|
|
1627
|
+
);
|
|
1628
|
+
|
|
1629
|
+
try {
|
|
1630
|
+
const allowed = manager.checkPermission("bash", {
|
|
1631
|
+
command: "rm -rf build",
|
|
1632
|
+
});
|
|
1633
|
+
expect(allowed.state).toBe("allow");
|
|
1634
|
+
expect(allowed.matchedPattern).toBe("rm -rf build");
|
|
1635
|
+
|
|
1636
|
+
const denied = manager.checkPermission("bash", {
|
|
1637
|
+
command: "rm -rf node_modules",
|
|
1638
|
+
});
|
|
1639
|
+
expect(denied.state).toBe("deny");
|
|
1640
|
+
expect(denied.matchedPattern).toBe("rm -rf *");
|
|
1641
|
+
} finally {
|
|
1642
|
+
cleanup();
|
|
1643
|
+
}
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
test("System-agent config overrides project-level bash patterns", () => {
|
|
1647
|
+
const { manager, cleanup } = createManagerWithProject(
|
|
1648
|
+
{
|
|
1649
|
+
permission: { "*": "allow", bash: "ask" },
|
|
1650
|
+
},
|
|
1651
|
+
{
|
|
1652
|
+
reviewer: `---
|
|
1653
|
+
name: reviewer
|
|
1654
|
+
permission:
|
|
1655
|
+
bash:
|
|
1656
|
+
"git log *": allow
|
|
1657
|
+
---
|
|
1658
|
+
`,
|
|
1659
|
+
},
|
|
1660
|
+
{
|
|
1661
|
+
projectConfig: {
|
|
1662
|
+
permission: { bash: { "git *": "deny" } },
|
|
1663
|
+
},
|
|
1664
|
+
},
|
|
1665
|
+
);
|
|
1666
|
+
|
|
1667
|
+
try {
|
|
1668
|
+
const allowed = manager.checkPermission(
|
|
1669
|
+
"bash",
|
|
1670
|
+
{ command: "git log --oneline" },
|
|
1671
|
+
"reviewer",
|
|
1672
|
+
);
|
|
1673
|
+
expect(allowed.state).toBe("allow");
|
|
1674
|
+
expect(allowed.matchedPattern).toBe("git log *");
|
|
1675
|
+
|
|
1676
|
+
const denied = manager.checkPermission(
|
|
1677
|
+
"bash",
|
|
1678
|
+
{ command: "git status" },
|
|
1679
|
+
"reviewer",
|
|
1680
|
+
);
|
|
1681
|
+
expect(denied.state).toBe("deny");
|
|
1682
|
+
expect(denied.matchedPattern).toBe("git *");
|
|
1683
|
+
} finally {
|
|
1684
|
+
cleanup();
|
|
1685
|
+
}
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
test("Project-agent config overrides system-agent tool rules", () => {
|
|
1689
|
+
const { manager, cleanup } = createManagerWithProject(
|
|
1690
|
+
{
|
|
1691
|
+
permission: { "*": "ask" },
|
|
1692
|
+
},
|
|
1693
|
+
{
|
|
1694
|
+
reviewer: `---
|
|
1695
|
+
name: reviewer
|
|
1696
|
+
permission:
|
|
1697
|
+
read: deny
|
|
1698
|
+
---
|
|
1699
|
+
`,
|
|
1700
|
+
},
|
|
1701
|
+
{
|
|
1702
|
+
projectAgentFiles: {
|
|
1703
|
+
reviewer: `---
|
|
1704
|
+
name: reviewer
|
|
1705
|
+
permission:
|
|
1706
|
+
read: allow
|
|
1707
|
+
---
|
|
1708
|
+
`,
|
|
1709
|
+
},
|
|
1710
|
+
},
|
|
1711
|
+
);
|
|
1712
|
+
|
|
1713
|
+
try {
|
|
1714
|
+
const result = manager.checkPermission("read", {}, "reviewer");
|
|
1715
|
+
expect(result.state).toBe("allow");
|
|
1716
|
+
expect(result.source).toBe("tool");
|
|
1717
|
+
} finally {
|
|
1718
|
+
cleanup();
|
|
1719
|
+
}
|
|
1720
|
+
});
|
|
1721
|
+
|
|
1722
|
+
test("Full precedence chain base < project < system-agent < project-agent for universal default", () => {
|
|
1723
|
+
const { manager, cleanup } = createManagerWithProject(
|
|
1724
|
+
{
|
|
1725
|
+
permission: { "*": "deny" },
|
|
1726
|
+
},
|
|
1727
|
+
{
|
|
1728
|
+
reviewer: `---
|
|
1729
|
+
name: reviewer
|
|
1730
|
+
permission:
|
|
1731
|
+
"*": ask
|
|
1732
|
+
---
|
|
1733
|
+
`,
|
|
1734
|
+
},
|
|
1735
|
+
{
|
|
1736
|
+
projectConfig: {
|
|
1737
|
+
permission: { "*": "allow" },
|
|
1738
|
+
},
|
|
1739
|
+
projectAgentFiles: {
|
|
1740
|
+
reviewer: `---
|
|
1741
|
+
name: reviewer
|
|
1742
|
+
permission:
|
|
1743
|
+
"*": deny
|
|
1744
|
+
---
|
|
1745
|
+
`,
|
|
1746
|
+
},
|
|
1747
|
+
},
|
|
1748
|
+
);
|
|
1749
|
+
|
|
1750
|
+
try {
|
|
1751
|
+
const reviewerResult = manager.checkPermission(
|
|
1752
|
+
"custom_extension_tool",
|
|
1753
|
+
{},
|
|
1754
|
+
"reviewer",
|
|
1755
|
+
);
|
|
1756
|
+
expect(reviewerResult.state).toBe("deny");
|
|
1757
|
+
expect(reviewerResult.source).toBe("default");
|
|
1758
|
+
|
|
1759
|
+
const globalResult = manager.checkPermission("custom_extension_tool", {});
|
|
1760
|
+
expect(globalResult.state).toBe("allow");
|
|
1761
|
+
expect(globalResult.source).toBe("default");
|
|
1762
|
+
} finally {
|
|
1763
|
+
cleanup();
|
|
1764
|
+
}
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1767
|
+
test("Project-agent applies even without a matching system-agent file", () => {
|
|
1768
|
+
const { manager, cleanup } = createManagerWithProject(
|
|
1769
|
+
{
|
|
1770
|
+
permission: { "*": "allow" },
|
|
1771
|
+
},
|
|
1772
|
+
{},
|
|
1773
|
+
{
|
|
1774
|
+
projectAgentFiles: {
|
|
1775
|
+
reviewer: `---
|
|
1776
|
+
name: reviewer
|
|
1777
|
+
permission:
|
|
1778
|
+
read: deny
|
|
1779
|
+
---
|
|
1780
|
+
`,
|
|
1781
|
+
},
|
|
1782
|
+
},
|
|
1783
|
+
);
|
|
1784
|
+
|
|
1785
|
+
try {
|
|
1786
|
+
const agentResult = manager.checkPermission("read", {}, "reviewer");
|
|
1787
|
+
expect(agentResult.state).toBe("deny");
|
|
1788
|
+
expect(agentResult.source).toBe("tool");
|
|
1789
|
+
|
|
1790
|
+
const globalResult = manager.checkPermission("read", {});
|
|
1791
|
+
expect(globalResult.state).toBe("allow");
|
|
1792
|
+
expect(globalResult.source).toBe("tool");
|
|
1793
|
+
} finally {
|
|
1794
|
+
cleanup();
|
|
1795
|
+
}
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
// ---------------------------------------------------------------------------
|
|
1799
|
+
// PermissionManager surface resolution — moved from catch-all (#342)
|
|
1800
|
+
// ---------------------------------------------------------------------------
|
|
1801
|
+
|
|
1802
|
+
test("PermissionManager canonical built-in permission checking", () => {
|
|
1803
|
+
const { manager, cleanup } = createManager({
|
|
1804
|
+
permission: { "*": "deny", read: "allow" },
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
try {
|
|
1808
|
+
const readResult = manager.checkPermission("read", {});
|
|
1809
|
+
expect(readResult.state).toBe("allow");
|
|
1810
|
+
expect(readResult.source).toBe("tool");
|
|
1811
|
+
|
|
1812
|
+
const writeResult = manager.checkPermission("write", {});
|
|
1813
|
+
expect(writeResult.state).toBe("deny");
|
|
1814
|
+
expect(writeResult.source).toBe("tool");
|
|
1815
|
+
} finally {
|
|
1816
|
+
cleanup();
|
|
1817
|
+
}
|
|
1818
|
+
});
|
|
1819
|
+
|
|
1820
|
+
test("multiline bash command resolves to allow via universal fallback", () => {
|
|
1821
|
+
// Regression test for #73: node -e "..." with embedded newlines was
|
|
1822
|
+
// falling through to the hard-coded 'ask' default because wildcardMatch
|
|
1823
|
+
// used /^.*$/ (no dotAll), which does not match '\n'.
|
|
1824
|
+
const { manager, cleanup } = createManager({
|
|
1825
|
+
permission: {
|
|
1826
|
+
"*": "allow",
|
|
1827
|
+
bash: { "rm -rf *": "deny", "sudo *": "ask" },
|
|
1828
|
+
},
|
|
1829
|
+
});
|
|
1830
|
+
|
|
1831
|
+
try {
|
|
1832
|
+
const command =
|
|
1833
|
+
"node -e \"\nimport('x').then(() => {\n console.log('done');\n});\n\"";
|
|
1834
|
+
const result = manager.checkPermission("bash", { command });
|
|
1835
|
+
expect(result.state).toBe("allow");
|
|
1836
|
+
} finally {
|
|
1837
|
+
cleanup();
|
|
1838
|
+
}
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
test("Bash specific deny patterns override catch-all within the same config", () => {
|
|
1842
|
+
// In the flat format, patterns within a surface map are ordered by insertion.
|
|
1843
|
+
// Last-match-wins means specific patterns placed AFTER the catch-all override it.
|
|
1844
|
+
const { manager, cleanup } = createManager({
|
|
1845
|
+
permission: {
|
|
1846
|
+
"*": "ask",
|
|
1847
|
+
bash: { "*": "allow", "rm -rf *": "deny" },
|
|
1848
|
+
},
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
try {
|
|
1852
|
+
const denied = manager.checkPermission("bash", {
|
|
1853
|
+
command: "rm -rf build",
|
|
1854
|
+
});
|
|
1855
|
+
expect(denied.state).toBe("deny");
|
|
1856
|
+
expect(denied.source).toBe("bash");
|
|
1857
|
+
expect(denied.matchedPattern).toBe("rm -rf *");
|
|
1858
|
+
|
|
1859
|
+
const allowed = manager.checkPermission("bash", { command: "echo hello" });
|
|
1860
|
+
expect(allowed.state).toBe("allow");
|
|
1861
|
+
expect(allowed.source).toBe("bash");
|
|
1862
|
+
expect(allowed.matchedPattern).toBe("*");
|
|
1863
|
+
} finally {
|
|
1864
|
+
cleanup();
|
|
1865
|
+
}
|
|
1866
|
+
});
|
|
1867
|
+
|
|
1868
|
+
test("MCP wildcard matching uses the registered mcp tool", () => {
|
|
1869
|
+
const { manager, cleanup } = createManager({
|
|
1870
|
+
permission: {
|
|
1871
|
+
"*": "ask",
|
|
1872
|
+
mcp: { "*": "deny", "research_*": "ask", "research_query-*": "allow" },
|
|
1873
|
+
},
|
|
1874
|
+
});
|
|
1875
|
+
|
|
1876
|
+
try {
|
|
1877
|
+
const queryDocs = manager.checkPermission("mcp", {
|
|
1878
|
+
tool: "research:query-docs",
|
|
1879
|
+
});
|
|
1880
|
+
expect(queryDocs.state).toBe("allow");
|
|
1881
|
+
expect(queryDocs.source).toBe("mcp");
|
|
1882
|
+
expect(queryDocs.matchedPattern).toBe("research_query-*");
|
|
1883
|
+
expect(queryDocs.target).toBe("research_query-docs");
|
|
1884
|
+
|
|
1885
|
+
const resolve2 = manager.checkPermission("mcp", {
|
|
1886
|
+
tool: "research:resolve-context",
|
|
1887
|
+
});
|
|
1888
|
+
expect(resolve2.state).toBe("ask");
|
|
1889
|
+
expect(resolve2.matchedPattern).toBe("research_*");
|
|
1890
|
+
expect(resolve2.target).toBe("research_resolve-context");
|
|
1891
|
+
|
|
1892
|
+
const unknown = manager.checkPermission("mcp", {
|
|
1893
|
+
tool: "search:provider",
|
|
1894
|
+
});
|
|
1895
|
+
expect(unknown.state).toBe("deny");
|
|
1896
|
+
expect(unknown.matchedPattern).toBe("*");
|
|
1897
|
+
expect(unknown.target).toBe("search_provider");
|
|
1898
|
+
} finally {
|
|
1899
|
+
cleanup();
|
|
1900
|
+
}
|
|
1901
|
+
});
|
|
1902
|
+
|
|
1903
|
+
test("Arbitrary extension tools use exact-name tool permissions instead of MCP fallback", () => {
|
|
1904
|
+
const { manager, cleanup } = createManager({
|
|
1905
|
+
permission: {
|
|
1906
|
+
"*": "deny",
|
|
1907
|
+
third_party_tool: "allow",
|
|
1908
|
+
mcp: { "*": "deny" },
|
|
1909
|
+
},
|
|
1910
|
+
});
|
|
1911
|
+
|
|
1912
|
+
try {
|
|
1913
|
+
const allowed = manager.checkPermission("third_party_tool", {});
|
|
1914
|
+
expect(allowed.state).toBe("allow");
|
|
1915
|
+
expect(allowed.source).toBe("tool");
|
|
1916
|
+
|
|
1917
|
+
// another_extension_tool has no explicit rule — falls through to the
|
|
1918
|
+
// universal default (permission["*"] = "deny") with source "default".
|
|
1919
|
+
const fallback = manager.checkPermission("another_extension_tool", {});
|
|
1920
|
+
expect(fallback.state).toBe("deny");
|
|
1921
|
+
expect(fallback.source).toBe("default");
|
|
1922
|
+
} finally {
|
|
1923
|
+
cleanup();
|
|
1924
|
+
}
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1927
|
+
test("Skill permission matching", () => {
|
|
1928
|
+
const { manager, cleanup } = createManager({
|
|
1929
|
+
permission: {
|
|
1930
|
+
"*": "ask",
|
|
1931
|
+
skill: {
|
|
1932
|
+
"*": "ask",
|
|
1933
|
+
"web-*": "deny",
|
|
1934
|
+
"requesting-code-review": "allow",
|
|
1935
|
+
},
|
|
1936
|
+
},
|
|
1937
|
+
});
|
|
1938
|
+
|
|
1939
|
+
try {
|
|
1940
|
+
const allowed = manager.checkPermission("skill", {
|
|
1941
|
+
name: "requesting-code-review",
|
|
1942
|
+
});
|
|
1943
|
+
expect(allowed.state).toBe("allow");
|
|
1944
|
+
expect(allowed.matchedPattern).toBe("requesting-code-review");
|
|
1945
|
+
expect(allowed.source).toBe("skill");
|
|
1946
|
+
|
|
1947
|
+
const denied = manager.checkPermission("skill", {
|
|
1948
|
+
name: "web-design-guidelines",
|
|
1949
|
+
});
|
|
1950
|
+
expect(denied.state).toBe("deny");
|
|
1951
|
+
expect(denied.matchedPattern).toBe("web-*");
|
|
1952
|
+
|
|
1953
|
+
const fallback = manager.checkPermission("skill", {
|
|
1954
|
+
name: "unknown-skill",
|
|
1955
|
+
});
|
|
1956
|
+
expect(fallback.state).toBe("ask");
|
|
1957
|
+
expect(fallback.matchedPattern).toBe("*");
|
|
1958
|
+
} finally {
|
|
1959
|
+
cleanup();
|
|
1960
|
+
}
|
|
1961
|
+
});
|
|
1962
|
+
|
|
1963
|
+
test("MCP proxy tool infers server-prefixed aliases from configured server names", () => {
|
|
1964
|
+
const { manager, cleanup } = createManager(
|
|
1965
|
+
{
|
|
1966
|
+
permission: {
|
|
1967
|
+
"*": "ask",
|
|
1968
|
+
mcp: { "exa_*": "deny", exa_get_code_context_exa: "allow" },
|
|
1969
|
+
},
|
|
1970
|
+
},
|
|
1971
|
+
{},
|
|
1972
|
+
{ mcpServerNames: ["exa"] },
|
|
1973
|
+
);
|
|
1974
|
+
|
|
1975
|
+
try {
|
|
1976
|
+
const result = manager.checkPermission("mcp", {
|
|
1977
|
+
tool: "get_code_context_exa",
|
|
1978
|
+
});
|
|
1979
|
+
expect(result.state).toBe("allow");
|
|
1980
|
+
expect(result.source).toBe("mcp");
|
|
1981
|
+
expect(result.matchedPattern).toBe("exa_get_code_context_exa");
|
|
1982
|
+
expect(result.target).toBe("exa_get_code_context_exa");
|
|
1983
|
+
} finally {
|
|
1984
|
+
cleanup();
|
|
1985
|
+
}
|
|
1986
|
+
});
|
|
1987
|
+
|
|
1988
|
+
test("MCP server names in settings.json are not used — only mcp.json is consulted", () => {
|
|
1989
|
+
const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-test-"));
|
|
1990
|
+
const globalConfigPath = join(baseDir, "pi-permissions.jsonc");
|
|
1991
|
+
const mcpConfigPath = join(baseDir, "mcp.json");
|
|
1992
|
+
const settingsJsonPath = join(baseDir, "settings.json");
|
|
1993
|
+
const agentsDir = join(baseDir, "agents");
|
|
1994
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
1995
|
+
|
|
1996
|
+
const config: ScopeConfig = {
|
|
1997
|
+
permission: { "*": "ask", mcp: { "legacy-server_*": "allow" } },
|
|
1998
|
+
};
|
|
1999
|
+
|
|
2000
|
+
writeFileSync(
|
|
2001
|
+
globalConfigPath,
|
|
2002
|
+
`${JSON.stringify(config, null, 2)}\n`,
|
|
2003
|
+
"utf8",
|
|
2004
|
+
);
|
|
2005
|
+
writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: {} }), "utf8");
|
|
2006
|
+
writeFileSync(
|
|
2007
|
+
settingsJsonPath,
|
|
2008
|
+
JSON.stringify({ mcpServers: { "legacy-server": {} } }),
|
|
2009
|
+
"utf8",
|
|
2010
|
+
);
|
|
2011
|
+
|
|
2012
|
+
const manager = new PermissionManager({
|
|
2013
|
+
globalConfigPath,
|
|
2014
|
+
agentsDir,
|
|
2015
|
+
globalMcpConfigPath: mcpConfigPath,
|
|
2016
|
+
});
|
|
2017
|
+
|
|
2018
|
+
try {
|
|
2019
|
+
const result = manager.checkPermission("mcp", {
|
|
2020
|
+
tool: "some_tool_legacy-server",
|
|
2021
|
+
});
|
|
2022
|
+
expect(result.state).toBe("ask");
|
|
2023
|
+
} finally {
|
|
2024
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
2025
|
+
}
|
|
2026
|
+
});
|
|
2027
|
+
|
|
2028
|
+
test("MCP describe mode normalizes qualified tool names without duplicating server prefixes", () => {
|
|
2029
|
+
const { manager, cleanup } = createManager(
|
|
2030
|
+
{
|
|
2031
|
+
permission: {
|
|
2032
|
+
"*": "ask",
|
|
2033
|
+
mcp: { "exa_*": "deny", exa_web_search_exa: "allow" },
|
|
2034
|
+
},
|
|
2035
|
+
},
|
|
2036
|
+
{},
|
|
2037
|
+
{ mcpServerNames: ["exa"] },
|
|
2038
|
+
);
|
|
2039
|
+
|
|
2040
|
+
try {
|
|
2041
|
+
const result = manager.checkPermission("mcp", {
|
|
2042
|
+
describe: "exa:web_search_exa",
|
|
2043
|
+
server: "exa",
|
|
2044
|
+
});
|
|
2045
|
+
expect(result.state).toBe("allow");
|
|
2046
|
+
expect(result.source).toBe("mcp");
|
|
2047
|
+
expect(result.matchedPattern).toBe("exa_web_search_exa");
|
|
2048
|
+
expect(result.target).toBe("exa_web_search_exa");
|
|
2049
|
+
} finally {
|
|
2050
|
+
cleanup();
|
|
2051
|
+
}
|
|
2052
|
+
});
|
|
2053
|
+
|
|
2054
|
+
test("Canonical tools map directly without legacy aliases", () => {
|
|
2055
|
+
const { manager, cleanup } = createManager({
|
|
2056
|
+
permission: { "*": "ask", find: "allow", ls: "deny" },
|
|
2057
|
+
});
|
|
2058
|
+
|
|
2059
|
+
try {
|
|
2060
|
+
const findResult = manager.checkPermission("find", {});
|
|
2061
|
+
expect(findResult.state).toBe("allow");
|
|
2062
|
+
expect(findResult.source).toBe("tool");
|
|
2063
|
+
|
|
2064
|
+
const lsResult = manager.checkPermission("ls", {});
|
|
2065
|
+
expect(lsResult.state).toBe("deny");
|
|
2066
|
+
expect(lsResult.source).toBe("tool");
|
|
2067
|
+
} finally {
|
|
2068
|
+
cleanup();
|
|
2069
|
+
}
|
|
2070
|
+
});
|
|
2071
|
+
|
|
2072
|
+
test("mcp catch-all acts as fallback for unmatched MCP targets", () => {
|
|
2073
|
+
const { manager, cleanup } = createManager(
|
|
2074
|
+
{
|
|
2075
|
+
permission: { "*": "ask" },
|
|
2076
|
+
},
|
|
2077
|
+
{
|
|
2078
|
+
reviewer: `---
|
|
2079
|
+
name: reviewer
|
|
2080
|
+
permission:
|
|
2081
|
+
mcp: allow
|
|
2082
|
+
---
|
|
2083
|
+
`,
|
|
2084
|
+
},
|
|
2085
|
+
);
|
|
2086
|
+
|
|
2087
|
+
try {
|
|
2088
|
+
const result = manager.checkPermission(
|
|
2089
|
+
"mcp",
|
|
2090
|
+
{ tool: "exa:web_search_exa" },
|
|
2091
|
+
"reviewer",
|
|
2092
|
+
);
|
|
2093
|
+
expect(result.state).toBe("allow");
|
|
2094
|
+
expect(result.source).toBe("mcp");
|
|
2095
|
+
expect(result.target).toBe("exa_web_search_exa");
|
|
2096
|
+
} finally {
|
|
2097
|
+
cleanup();
|
|
2098
|
+
}
|
|
2099
|
+
});
|
|
2100
|
+
|
|
2101
|
+
test("specific MCP rules override mcp catch-all", () => {
|
|
2102
|
+
const { manager, cleanup } = createManager(
|
|
2103
|
+
{
|
|
2104
|
+
permission: { "*": "ask" },
|
|
2105
|
+
},
|
|
2106
|
+
{
|
|
2107
|
+
reviewer: `---
|
|
2108
|
+
name: reviewer
|
|
2109
|
+
permission:
|
|
2110
|
+
mcp:
|
|
2111
|
+
"*": allow
|
|
2112
|
+
exa_web_search_exa: deny
|
|
2113
|
+
---
|
|
2114
|
+
`,
|
|
2115
|
+
},
|
|
2116
|
+
{ mcpServerNames: ["exa"] },
|
|
2117
|
+
);
|
|
2118
|
+
|
|
2119
|
+
try {
|
|
2120
|
+
const result = manager.checkPermission(
|
|
2121
|
+
"mcp",
|
|
2122
|
+
{ tool: "web_search_exa" },
|
|
2123
|
+
"reviewer",
|
|
2124
|
+
);
|
|
2125
|
+
expect(result.state).toBe("deny");
|
|
2126
|
+
expect(result.source).toBe("mcp");
|
|
2127
|
+
expect(result.matchedPattern).toBe("exa_web_search_exa");
|
|
2128
|
+
expect(result.target).toBe("exa_web_search_exa");
|
|
2129
|
+
} finally {
|
|
2130
|
+
cleanup();
|
|
2131
|
+
}
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
test("specific MCP rules still win when mcp catch-all is deny", () => {
|
|
2135
|
+
const { manager, cleanup } = createManager(
|
|
2136
|
+
{
|
|
2137
|
+
permission: { "*": "ask" },
|
|
2138
|
+
},
|
|
2139
|
+
{
|
|
2140
|
+
reviewer: `---
|
|
2141
|
+
name: reviewer
|
|
2142
|
+
permission:
|
|
2143
|
+
mcp:
|
|
2144
|
+
"*": deny
|
|
2145
|
+
exa_web_search_exa: allow
|
|
2146
|
+
---
|
|
2147
|
+
`,
|
|
2148
|
+
},
|
|
2149
|
+
{ mcpServerNames: ["exa"] },
|
|
2150
|
+
);
|
|
2151
|
+
|
|
2152
|
+
try {
|
|
2153
|
+
const allowed = manager.checkPermission(
|
|
2154
|
+
"mcp",
|
|
2155
|
+
{ tool: "web_search_exa" },
|
|
2156
|
+
"reviewer",
|
|
2157
|
+
);
|
|
2158
|
+
expect(allowed.state).toBe("allow");
|
|
2159
|
+
expect(allowed.source).toBe("mcp");
|
|
2160
|
+
expect(allowed.matchedPattern).toBe("exa_web_search_exa");
|
|
2161
|
+
expect(allowed.target).toBe("exa_web_search_exa");
|
|
2162
|
+
|
|
2163
|
+
const fallback = manager.checkPermission(
|
|
2164
|
+
"mcp",
|
|
2165
|
+
{ tool: "other_exa" },
|
|
2166
|
+
"reviewer",
|
|
2167
|
+
);
|
|
2168
|
+
expect(fallback.state).toBe("deny");
|
|
2169
|
+
expect(fallback.source).toBe("mcp");
|
|
2170
|
+
expect(fallback.target).toBe("exa_other_exa");
|
|
2171
|
+
} finally {
|
|
2172
|
+
cleanup();
|
|
2173
|
+
}
|
|
2174
|
+
});
|
|
2175
|
+
|
|
2176
|
+
test("mcp catch-all in agent frontmatter overrides global default", () => {
|
|
2177
|
+
const { manager, cleanup } = createManager(
|
|
2178
|
+
{
|
|
2179
|
+
permission: { "*": "deny" },
|
|
2180
|
+
},
|
|
2181
|
+
{
|
|
2182
|
+
reviewer: `---
|
|
2183
|
+
name: reviewer
|
|
2184
|
+
permission:
|
|
2185
|
+
mcp: allow
|
|
2186
|
+
---
|
|
2187
|
+
`,
|
|
2188
|
+
},
|
|
2189
|
+
);
|
|
2190
|
+
|
|
2191
|
+
try {
|
|
2192
|
+
const readResult = manager.checkPermission("read", {}, "reviewer");
|
|
2193
|
+
expect(readResult.state).toBe("deny");
|
|
2194
|
+
expect(readResult.source).toBe("tool");
|
|
2195
|
+
|
|
2196
|
+
const mcpResult = manager.checkPermission(
|
|
2197
|
+
"mcp",
|
|
2198
|
+
{ tool: "exa:web_search_exa" },
|
|
2199
|
+
"reviewer",
|
|
2200
|
+
);
|
|
2201
|
+
expect(mcpResult.state).toBe("allow");
|
|
2202
|
+
expect(mcpResult.source).toBe("mcp");
|
|
2203
|
+
} finally {
|
|
2204
|
+
cleanup();
|
|
2205
|
+
}
|
|
2206
|
+
});
|
|
2207
|
+
|
|
2208
|
+
test("Agent frontmatter canonical tools resolve correctly", () => {
|
|
2209
|
+
const { manager, cleanup } = createManager(
|
|
2210
|
+
{
|
|
2211
|
+
permission: { "*": "deny" },
|
|
2212
|
+
},
|
|
2213
|
+
{
|
|
2214
|
+
reviewer: `---
|
|
2215
|
+
name: reviewer
|
|
2216
|
+
permission:
|
|
2217
|
+
find: allow
|
|
2218
|
+
ls: deny
|
|
2219
|
+
---
|
|
2220
|
+
`,
|
|
2221
|
+
},
|
|
2222
|
+
);
|
|
2223
|
+
|
|
2224
|
+
try {
|
|
2225
|
+
const findResult = manager.checkPermission("find", {}, "reviewer");
|
|
2226
|
+
expect(findResult.state).toBe("allow");
|
|
2227
|
+
expect(findResult.source).toBe("tool");
|
|
2228
|
+
|
|
2229
|
+
const lsResult = manager.checkPermission("ls", {}, "reviewer");
|
|
2230
|
+
expect(lsResult.state).toBe("deny");
|
|
2231
|
+
expect(lsResult.source).toBe("tool");
|
|
2232
|
+
} finally {
|
|
2233
|
+
cleanup();
|
|
2234
|
+
}
|
|
2235
|
+
});
|
|
2236
|
+
|
|
2237
|
+
test("All surface names work in agent frontmatter flat permission format", () => {
|
|
2238
|
+
const { manager, cleanup } = createManager(
|
|
2239
|
+
{
|
|
2240
|
+
permission: { "*": "deny" },
|
|
2241
|
+
},
|
|
2242
|
+
{
|
|
2243
|
+
reviewer: `---
|
|
2244
|
+
name: reviewer
|
|
2245
|
+
permission:
|
|
2246
|
+
find: allow
|
|
2247
|
+
task: allow
|
|
2248
|
+
mcp: allow
|
|
2249
|
+
---
|
|
2250
|
+
`,
|
|
2251
|
+
},
|
|
2252
|
+
);
|
|
2253
|
+
|
|
2254
|
+
try {
|
|
2255
|
+
const findResult = manager.checkPermission("find", {}, "reviewer");
|
|
2256
|
+
expect(findResult.state).toBe("allow");
|
|
2257
|
+
expect(findResult.source).toBe("tool");
|
|
2258
|
+
|
|
2259
|
+
const taskResult = manager.checkPermission("task", {}, "reviewer");
|
|
2260
|
+
expect(taskResult.state).toBe("allow");
|
|
2261
|
+
expect(taskResult.source).toBe("tool");
|
|
2262
|
+
|
|
2263
|
+
const mcpResult = manager.checkPermission(
|
|
2264
|
+
"mcp",
|
|
2265
|
+
{ tool: "exa:web_search_exa" },
|
|
2266
|
+
"reviewer",
|
|
2267
|
+
);
|
|
2268
|
+
expect(mcpResult.state).toBe("allow");
|
|
2269
|
+
} finally {
|
|
2270
|
+
cleanup();
|
|
2271
|
+
}
|
|
2272
|
+
});
|
|
2273
|
+
|
|
2274
|
+
test("task uses exact-name tool permissions like any registered extension tool", () => {
|
|
2275
|
+
const { manager, cleanup } = createManager({
|
|
2276
|
+
permission: { "*": "deny", task: "allow" },
|
|
2277
|
+
});
|
|
2278
|
+
|
|
2279
|
+
try {
|
|
2280
|
+
const taskResult = manager.checkPermission("task", {});
|
|
2281
|
+
expect(taskResult.state).toBe("allow");
|
|
2282
|
+
expect(taskResult.source).toBe("tool");
|
|
2283
|
+
} finally {
|
|
2284
|
+
cleanup();
|
|
2285
|
+
}
|
|
2286
|
+
});
|
|
2287
|
+
|
|
2288
|
+
test("getToolPermission returns tool-level policy for canonical and extension tools", () => {
|
|
2289
|
+
const { manager, cleanup } = createManager(
|
|
2290
|
+
{
|
|
2291
|
+
permission: { "*": "ask" },
|
|
2292
|
+
},
|
|
2293
|
+
{
|
|
2294
|
+
reviewer: `---
|
|
2295
|
+
name: reviewer
|
|
2296
|
+
permission:
|
|
2297
|
+
bash: deny
|
|
2298
|
+
read: deny
|
|
2299
|
+
task: allow
|
|
2300
|
+
---
|
|
2301
|
+
`,
|
|
2302
|
+
},
|
|
2303
|
+
);
|
|
2304
|
+
|
|
2305
|
+
try {
|
|
2306
|
+
const bashPermission = manager.getToolPermission("bash", "reviewer");
|
|
2307
|
+
expect(bashPermission).toBe("deny");
|
|
2308
|
+
|
|
2309
|
+
const taskPermission = manager.getToolPermission("task", "reviewer");
|
|
2310
|
+
expect(taskPermission).toBe("allow");
|
|
2311
|
+
|
|
2312
|
+
const readPermission = manager.getToolPermission("read", "reviewer");
|
|
2313
|
+
expect(readPermission).toBe("deny");
|
|
2314
|
+
|
|
2315
|
+
const defaultBashPermission = manager.getToolPermission("bash");
|
|
2316
|
+
expect(defaultBashPermission).toBe("ask");
|
|
2317
|
+
|
|
2318
|
+
const { manager: manager2, cleanup: cleanup2 } = createManager({
|
|
2319
|
+
permission: { "*": "deny", bash: "allow" },
|
|
2320
|
+
});
|
|
2321
|
+
|
|
2322
|
+
try {
|
|
2323
|
+
const globalBashPermission = manager2.getToolPermission("bash");
|
|
2324
|
+
expect(globalBashPermission).toBe("allow");
|
|
2325
|
+
} finally {
|
|
2326
|
+
cleanup2();
|
|
2327
|
+
}
|
|
2328
|
+
} finally {
|
|
2329
|
+
cleanup();
|
|
2330
|
+
}
|
|
2331
|
+
});
|
|
2332
|
+
|
|
2333
|
+
test("getToolPermission supports arbitrary extension tool names", () => {
|
|
2334
|
+
const { manager, cleanup } = createManager({
|
|
2335
|
+
permission: { "*": "deny", third_party_tool: "allow" },
|
|
2336
|
+
});
|
|
2337
|
+
|
|
2338
|
+
try {
|
|
2339
|
+
const explicitPermission = manager.getToolPermission("third_party_tool");
|
|
2340
|
+
expect(explicitPermission).toBe("allow");
|
|
2341
|
+
|
|
2342
|
+
const fallbackPermission = manager.getToolPermission(
|
|
2343
|
+
"missing_extension_tool",
|
|
2344
|
+
);
|
|
2345
|
+
expect(fallbackPermission).toBe("deny");
|
|
2346
|
+
} finally {
|
|
2347
|
+
cleanup();
|
|
2348
|
+
}
|
|
2349
|
+
});
|
|
2350
|
+
|
|
2351
|
+
// ---------------------------------------------------------------------------
|
|
2352
|
+
// external_directory config resolution and pattern maps — moved from catch-all (#342)
|
|
2353
|
+
// ---------------------------------------------------------------------------
|
|
2354
|
+
|
|
2355
|
+
test("external_directory permission falls back to universal default when not explicitly configured", () => {
|
|
2356
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2357
|
+
|
|
2358
|
+
try {
|
|
2359
|
+
const result = manager.checkPermission("external_directory", {});
|
|
2360
|
+
expect(result.state).toBe("ask");
|
|
2361
|
+
expect(result.source).toBe("special");
|
|
2362
|
+
expect(result.matchedPattern).toBe(undefined);
|
|
2363
|
+
} finally {
|
|
2364
|
+
cleanup();
|
|
2365
|
+
}
|
|
2366
|
+
});
|
|
2367
|
+
|
|
2368
|
+
test("external_directory permission respects explicit deny", () => {
|
|
2369
|
+
const { manager, cleanup } = createManager({
|
|
2370
|
+
permission: { "*": "allow", external_directory: "deny" },
|
|
2371
|
+
});
|
|
2372
|
+
|
|
2373
|
+
try {
|
|
2374
|
+
const result = manager.checkPermission("external_directory", {});
|
|
2375
|
+
expect(result.state).toBe("deny");
|
|
2376
|
+
expect(result.source).toBe("special");
|
|
2377
|
+
expect(result.matchedPattern).toBe("*");
|
|
2378
|
+
} finally {
|
|
2379
|
+
cleanup();
|
|
2380
|
+
}
|
|
2381
|
+
});
|
|
2382
|
+
|
|
2383
|
+
test("external_directory permission can be explicitly allowed", () => {
|
|
2384
|
+
const { manager, cleanup } = createManager({
|
|
2385
|
+
permission: { "*": "allow", external_directory: "allow" },
|
|
2386
|
+
});
|
|
2387
|
+
|
|
2388
|
+
try {
|
|
2389
|
+
const result = manager.checkPermission("external_directory", {});
|
|
2390
|
+
expect(result.state).toBe("allow");
|
|
2391
|
+
expect(result.source).toBe("special");
|
|
2392
|
+
expect(result.matchedPattern).toBe("*");
|
|
2393
|
+
} finally {
|
|
2394
|
+
cleanup();
|
|
2395
|
+
}
|
|
2396
|
+
});
|
|
2397
|
+
|
|
2398
|
+
test("external_directory permission respects per-agent override", () => {
|
|
2399
|
+
const { manager, cleanup } = createManager(
|
|
2400
|
+
{
|
|
2401
|
+
permission: { "*": "allow", external_directory: "deny" },
|
|
2402
|
+
},
|
|
2403
|
+
{
|
|
2404
|
+
trusted: `---
|
|
2405
|
+
name: trusted
|
|
2406
|
+
permission:
|
|
2407
|
+
external_directory: allow
|
|
2408
|
+
---
|
|
2409
|
+
`,
|
|
2410
|
+
},
|
|
2411
|
+
);
|
|
2412
|
+
|
|
2413
|
+
try {
|
|
2414
|
+
const globalResult = manager.checkPermission("external_directory", {});
|
|
2415
|
+
expect(globalResult.state).toBe("deny");
|
|
2416
|
+
|
|
2417
|
+
const agentResult = manager.checkPermission(
|
|
2418
|
+
"external_directory",
|
|
2419
|
+
{},
|
|
2420
|
+
"trusted",
|
|
2421
|
+
);
|
|
2422
|
+
expect(agentResult.state).toBe("allow");
|
|
2423
|
+
expect(agentResult.source).toBe("special");
|
|
2424
|
+
} finally {
|
|
2425
|
+
cleanup();
|
|
2426
|
+
}
|
|
2427
|
+
});
|
|
2428
|
+
|
|
2429
|
+
test("external_directory permission is not affected by unrelated surface keys", () => {
|
|
2430
|
+
const { manager, cleanup } = createManager({
|
|
2431
|
+
permission: { "*": "allow", external_directory: "allow" },
|
|
2432
|
+
});
|
|
2433
|
+
|
|
2434
|
+
try {
|
|
2435
|
+
const extResult = manager.checkPermission("external_directory", {});
|
|
2436
|
+
expect(extResult.state).toBe("allow");
|
|
2437
|
+
expect(extResult.matchedPattern).toBe("*");
|
|
2438
|
+
} finally {
|
|
2439
|
+
cleanup();
|
|
2440
|
+
}
|
|
2441
|
+
});
|
|
2442
|
+
|
|
2443
|
+
test("skill pattern map in agent frontmatter overrides global skill policy", () => {
|
|
2444
|
+
const { manager, cleanup } = createManager(
|
|
2445
|
+
{
|
|
2446
|
+
permission: { "*": "deny", skill: "deny" },
|
|
2447
|
+
},
|
|
2448
|
+
{
|
|
2449
|
+
reviewer: `---
|
|
2450
|
+
name: reviewer
|
|
2451
|
+
permission:
|
|
2452
|
+
skill:
|
|
2453
|
+
"*": ask
|
|
2454
|
+
"pi-*": allow
|
|
2455
|
+
---
|
|
2456
|
+
`,
|
|
2457
|
+
},
|
|
2458
|
+
);
|
|
2459
|
+
|
|
2460
|
+
try {
|
|
2461
|
+
const allowed = manager.checkPermission(
|
|
2462
|
+
"skill",
|
|
2463
|
+
{ name: "pi-code-review" },
|
|
2464
|
+
"reviewer",
|
|
2465
|
+
);
|
|
2466
|
+
expect(allowed.state).toBe("allow");
|
|
2467
|
+
expect(allowed.matchedPattern).toBe("pi-*");
|
|
2468
|
+
expect(allowed.source).toBe("skill");
|
|
2469
|
+
|
|
2470
|
+
const asked = manager.checkPermission(
|
|
2471
|
+
"skill",
|
|
2472
|
+
{ name: "other-skill" },
|
|
2473
|
+
"reviewer",
|
|
2474
|
+
);
|
|
2475
|
+
expect(asked.state).toBe("ask");
|
|
2476
|
+
expect(asked.matchedPattern).toBe("*");
|
|
2477
|
+
|
|
2478
|
+
const denied = manager.checkPermission("skill", { name: "pi-code-review" });
|
|
2479
|
+
expect(denied.state).toBe("deny");
|
|
2480
|
+
expect(denied.source).toBe("skill");
|
|
2481
|
+
} finally {
|
|
2482
|
+
cleanup();
|
|
2483
|
+
}
|
|
2484
|
+
});
|
|
2485
|
+
|
|
2486
|
+
test("external_directory pattern map in agent frontmatter overrides global policy", () => {
|
|
2487
|
+
const { manager, cleanup } = createManager(
|
|
2488
|
+
{
|
|
2489
|
+
permission: { "*": "allow", external_directory: "deny" },
|
|
2490
|
+
},
|
|
2491
|
+
{
|
|
2492
|
+
trusted: `---
|
|
2493
|
+
name: trusted
|
|
2494
|
+
permission:
|
|
2495
|
+
external_directory:
|
|
2496
|
+
"*": deny
|
|
2497
|
+
"~/Downloads/*": allow
|
|
2498
|
+
---
|
|
2499
|
+
`,
|
|
2500
|
+
},
|
|
2501
|
+
);
|
|
2502
|
+
|
|
2503
|
+
try {
|
|
2504
|
+
const allowed = manager.checkPermission(
|
|
2505
|
+
"external_directory",
|
|
2506
|
+
{ path: `${homedir()}/Downloads/file.txt` },
|
|
2507
|
+
"trusted",
|
|
2508
|
+
);
|
|
2509
|
+
expect(allowed.state).toBe("allow");
|
|
2510
|
+
expect(allowed.matchedPattern).toBe("~/Downloads/*");
|
|
2511
|
+
expect(allowed.source).toBe("special");
|
|
2512
|
+
|
|
2513
|
+
const denied = manager.checkPermission(
|
|
2514
|
+
"external_directory",
|
|
2515
|
+
{ path: `${homedir()}/Documents/secret.txt` },
|
|
2516
|
+
"trusted",
|
|
2517
|
+
);
|
|
2518
|
+
expect(denied.state).toBe("deny");
|
|
2519
|
+
expect(denied.matchedPattern).toBe("*");
|
|
2520
|
+
|
|
2521
|
+
const globalDenied = manager.checkPermission("external_directory", {});
|
|
2522
|
+
expect(globalDenied.state).toBe("deny");
|
|
2523
|
+
expect(globalDenied.source).toBe("special");
|
|
2524
|
+
} finally {
|
|
2525
|
+
cleanup();
|
|
2526
|
+
}
|
|
2527
|
+
});
|
|
2528
|
+
|
|
2529
|
+
test("project-agent frontmatter skill rules override global-agent frontmatter skill rules", () => {
|
|
2530
|
+
const { manager, cleanup } = createManagerWithProject(
|
|
2531
|
+
{
|
|
2532
|
+
permission: { "*": "deny" },
|
|
2533
|
+
},
|
|
2534
|
+
{
|
|
2535
|
+
analyst: `---
|
|
2536
|
+
name: analyst
|
|
2537
|
+
permission:
|
|
2538
|
+
skill:
|
|
2539
|
+
"*": ask
|
|
2540
|
+
---
|
|
2541
|
+
`,
|
|
2542
|
+
},
|
|
2543
|
+
{
|
|
2544
|
+
projectAgentFiles: {
|
|
2545
|
+
analyst: `---
|
|
2546
|
+
name: analyst
|
|
2547
|
+
permission:
|
|
2548
|
+
skill:
|
|
2549
|
+
"pi-*": allow
|
|
2550
|
+
"*": deny
|
|
2551
|
+
---
|
|
2552
|
+
`,
|
|
2553
|
+
},
|
|
2554
|
+
},
|
|
2555
|
+
);
|
|
2556
|
+
|
|
2557
|
+
try {
|
|
2558
|
+
const allowed = manager.checkPermission(
|
|
2559
|
+
"skill",
|
|
2560
|
+
{ name: "pi-code-review" },
|
|
2561
|
+
"analyst",
|
|
2562
|
+
);
|
|
2563
|
+
expect(allowed.state).toBe("allow");
|
|
2564
|
+
expect(allowed.matchedPattern).toBe("pi-*");
|
|
2565
|
+
|
|
2566
|
+
const denied = manager.checkPermission(
|
|
2567
|
+
"skill",
|
|
2568
|
+
{ name: "other-skill" },
|
|
2569
|
+
"analyst",
|
|
2570
|
+
);
|
|
2571
|
+
expect(denied.state).toBe("deny");
|
|
2572
|
+
expect(denied.matchedPattern).toBe("*");
|
|
2573
|
+
} finally {
|
|
2574
|
+
cleanup();
|
|
2575
|
+
}
|
|
2576
|
+
});
|
|
2577
|
+
|
|
2578
|
+
test("project-agent frontmatter external_directory rules override global-agent frontmatter rules", () => {
|
|
2579
|
+
const { manager, cleanup } = createManagerWithProject(
|
|
2580
|
+
{
|
|
2581
|
+
permission: { "*": "allow", external_directory: "deny" },
|
|
2582
|
+
},
|
|
2583
|
+
{
|
|
2584
|
+
analyst: `---
|
|
2585
|
+
name: analyst
|
|
2586
|
+
permission:
|
|
2587
|
+
external_directory: ask
|
|
2588
|
+
---
|
|
2589
|
+
`,
|
|
2590
|
+
},
|
|
2591
|
+
{
|
|
2592
|
+
projectAgentFiles: {
|
|
2593
|
+
analyst: `---
|
|
2594
|
+
name: analyst
|
|
2595
|
+
permission:
|
|
2596
|
+
external_directory: allow
|
|
2597
|
+
---
|
|
2598
|
+
`,
|
|
2599
|
+
},
|
|
2600
|
+
},
|
|
2601
|
+
);
|
|
2602
|
+
|
|
2603
|
+
try {
|
|
2604
|
+
const result = manager.checkPermission("external_directory", {}, "analyst");
|
|
2605
|
+
expect(result.state).toBe("allow");
|
|
2606
|
+
expect(result.source).toBe("special");
|
|
2607
|
+
|
|
2608
|
+
const globalResult = manager.checkPermission("external_directory", {});
|
|
2609
|
+
expect(globalResult.state).toBe("deny");
|
|
2610
|
+
} finally {
|
|
2611
|
+
cleanup();
|
|
2612
|
+
}
|
|
2613
|
+
});
|
|
2614
|
+
|
|
2615
|
+
// ---------------------------------------------------------------------------
|
|
2616
|
+
// PI_CODING_AGENT_DIR support — moved from catch-all (#342)
|
|
2617
|
+
// ---------------------------------------------------------------------------
|
|
2618
|
+
|
|
2619
|
+
test("PermissionManager reads config from PI_CODING_AGENT_DIR when set", () => {
|
|
2620
|
+
const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-envdir-"));
|
|
2621
|
+
const agentsDir = join(baseDir, "agents");
|
|
2622
|
+
const newConfigPath = getGlobalConfigPath(baseDir);
|
|
2623
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
2624
|
+
mkdirSync(dirname(newConfigPath), { recursive: true });
|
|
2625
|
+
|
|
2626
|
+
const config: ScopeConfig = {
|
|
2627
|
+
permission: { "*": "deny", read: "allow" },
|
|
2628
|
+
};
|
|
2629
|
+
writeFileSync(newConfigPath, JSON.stringify(config), "utf8");
|
|
2630
|
+
|
|
2631
|
+
const original = process.env.PI_CODING_AGENT_DIR;
|
|
2632
|
+
process.env.PI_CODING_AGENT_DIR = baseDir;
|
|
2633
|
+
try {
|
|
2634
|
+
const manager = new PermissionManager();
|
|
2635
|
+
const result = manager.checkPermission("read", {});
|
|
2636
|
+
expect(result.state).toBe("allow");
|
|
2637
|
+
|
|
2638
|
+
const result2 = manager.checkPermission("write", {});
|
|
2639
|
+
expect(result2.state).toBe("deny");
|
|
2640
|
+
} finally {
|
|
2641
|
+
if (original !== undefined) {
|
|
2642
|
+
process.env.PI_CODING_AGENT_DIR = original;
|
|
2643
|
+
} else {
|
|
2644
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
2645
|
+
}
|
|
2646
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
2647
|
+
}
|
|
2648
|
+
});
|
|
2649
|
+
|
|
2650
|
+
// ---------------------------------------------------------------------------
|
|
2651
|
+
// getConfigIssues — moved from catch-all (#342)
|
|
2652
|
+
// ---------------------------------------------------------------------------
|
|
2653
|
+
|
|
2654
|
+
test("PermissionManager.getConfigIssues returns empty array for clean config", () => {
|
|
2655
|
+
const config: ScopeConfig = {
|
|
2656
|
+
permission: { "*": "ask", external_directory: "ask" },
|
|
2657
|
+
};
|
|
2658
|
+
const { manager, cleanup } = createManager(config);
|
|
2659
|
+
try {
|
|
2660
|
+
const issues = manager.getConfigIssues();
|
|
2661
|
+
expect(issues.length).toBe(0);
|
|
2662
|
+
} finally {
|
|
2663
|
+
cleanup();
|
|
2664
|
+
}
|
|
2665
|
+
});
|
|
2666
|
+
|
|
2667
|
+
test("PermissionManager.getConfigIssues returns empty array for empty config", () => {
|
|
2668
|
+
const { manager, cleanup } = createManager({});
|
|
2669
|
+
try {
|
|
2670
|
+
const issues = manager.getConfigIssues();
|
|
2671
|
+
expect(issues.length).toBe(0);
|
|
2672
|
+
} finally {
|
|
2673
|
+
cleanup();
|
|
2674
|
+
}
|
|
2675
|
+
});
|
|
2676
|
+
|
|
2677
|
+
// ---------------------------------------------------------------------------
|
|
2678
|
+
// Session-aware checkPermission() — moved from catch-all (#342)
|
|
2679
|
+
// ---------------------------------------------------------------------------
|
|
2680
|
+
|
|
2681
|
+
test("checkPermission returns source 'session' when session rules cover the external_directory path", () => {
|
|
2682
|
+
const { manager, cleanup } = createManager({
|
|
2683
|
+
permission: { "*": "allow" },
|
|
2684
|
+
});
|
|
2685
|
+
|
|
2686
|
+
try {
|
|
2687
|
+
const sessionRules = [
|
|
2688
|
+
{
|
|
2689
|
+
surface: "external_directory",
|
|
2690
|
+
pattern: "/other/project/*",
|
|
2691
|
+
action: "allow" as const,
|
|
2692
|
+
layer: "session" as const,
|
|
2693
|
+
origin: "session" as const,
|
|
2694
|
+
},
|
|
2695
|
+
];
|
|
2696
|
+
|
|
2697
|
+
const result = manager.checkPermission(
|
|
2698
|
+
"external_directory",
|
|
2699
|
+
{ path: "/other/project/src/foo.ts" },
|
|
2700
|
+
undefined,
|
|
2701
|
+
sessionRules,
|
|
2702
|
+
);
|
|
2703
|
+
expect(result.state).toBe("allow");
|
|
2704
|
+
expect(result.source).toBe("session");
|
|
2705
|
+
expect(result.matchedPattern).toBe("/other/project/*");
|
|
2706
|
+
} finally {
|
|
2707
|
+
cleanup();
|
|
2708
|
+
}
|
|
2709
|
+
});
|
|
2710
|
+
|
|
2711
|
+
test("checkPermission falls back to config policy when session rules do not cover the path", () => {
|
|
2712
|
+
const { manager, cleanup } = createManager({
|
|
2713
|
+
permission: { "*": "allow", external_directory: "deny" },
|
|
2714
|
+
});
|
|
2715
|
+
|
|
2716
|
+
try {
|
|
2717
|
+
const sessionRules = [
|
|
2718
|
+
{
|
|
2719
|
+
surface: "external_directory",
|
|
2720
|
+
pattern: "/other/project/*",
|
|
2721
|
+
action: "allow" as const,
|
|
2722
|
+
layer: "session" as const,
|
|
2723
|
+
origin: "session" as const,
|
|
2724
|
+
},
|
|
2725
|
+
];
|
|
2726
|
+
|
|
2727
|
+
const result = manager.checkPermission(
|
|
2728
|
+
"external_directory",
|
|
2729
|
+
{ path: "/completely/different/path.ts" },
|
|
2730
|
+
undefined,
|
|
2731
|
+
sessionRules,
|
|
2732
|
+
);
|
|
2733
|
+
expect(result.state).toBe("deny");
|
|
2734
|
+
expect(result.source).toBe("special");
|
|
2735
|
+
} finally {
|
|
2736
|
+
cleanup();
|
|
2737
|
+
}
|
|
2738
|
+
});
|
|
2739
|
+
|
|
2740
|
+
test("checkPermission with empty session rules is identical to call without sessionRules arg", () => {
|
|
2741
|
+
const { manager, cleanup } = createManager({
|
|
2742
|
+
permission: { "*": "allow", external_directory: "deny" },
|
|
2743
|
+
});
|
|
2744
|
+
|
|
2745
|
+
try {
|
|
2746
|
+
const withEmpty = manager.checkPermission(
|
|
2747
|
+
"external_directory",
|
|
2748
|
+
{ path: "/other/project/foo.ts" },
|
|
2749
|
+
undefined,
|
|
2750
|
+
[],
|
|
2751
|
+
);
|
|
2752
|
+
const withoutArg = manager.checkPermission("external_directory", {
|
|
2753
|
+
path: "/other/project/foo.ts",
|
|
2754
|
+
});
|
|
2755
|
+
const expected: PermissionCheckResult = {
|
|
2756
|
+
toolName: "external_directory",
|
|
2757
|
+
state: "deny",
|
|
2758
|
+
matchedPattern: "*",
|
|
2759
|
+
source: "special",
|
|
2760
|
+
origin: "global",
|
|
2761
|
+
};
|
|
2762
|
+
expect(withEmpty).toEqual(expected);
|
|
2763
|
+
expect(withoutArg).toEqual(expected);
|
|
2764
|
+
} finally {
|
|
2765
|
+
cleanup();
|
|
2766
|
+
}
|
|
2767
|
+
});
|
|
2768
|
+
|
|
2769
|
+
test("session rules for one surface do not affect checks on other surfaces", () => {
|
|
2770
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2771
|
+
|
|
2772
|
+
try {
|
|
2773
|
+
const sessionRules = [
|
|
2774
|
+
{
|
|
2775
|
+
surface: "external_directory",
|
|
2776
|
+
pattern: "/other/project/*",
|
|
2777
|
+
action: "allow" as const,
|
|
2778
|
+
layer: "session" as const,
|
|
2779
|
+
origin: "session" as const,
|
|
2780
|
+
},
|
|
2781
|
+
];
|
|
2782
|
+
|
|
2783
|
+
const bashResult = manager.checkPermission(
|
|
2784
|
+
"bash",
|
|
2785
|
+
{ command: "git status" },
|
|
2786
|
+
undefined,
|
|
2787
|
+
sessionRules,
|
|
2788
|
+
);
|
|
2789
|
+
expect(bashResult.state).toBe("ask");
|
|
2790
|
+
expect(bashResult.source).toBe("bash");
|
|
2791
|
+
|
|
2792
|
+
const mcpResult = manager.checkPermission(
|
|
2793
|
+
"mcp",
|
|
2794
|
+
{ tool: "exa:search" },
|
|
2795
|
+
undefined,
|
|
2796
|
+
sessionRules,
|
|
2797
|
+
);
|
|
2798
|
+
expect(mcpResult.state).toBe("ask");
|
|
2799
|
+
expect(mcpResult.source).toBe("default");
|
|
2800
|
+
} finally {
|
|
2801
|
+
cleanup();
|
|
2802
|
+
}
|
|
2803
|
+
});
|
|
2804
|
+
|
|
2805
|
+
test("session rules override config deny for external_directory", () => {
|
|
2806
|
+
const { manager, cleanup } = createManager({
|
|
2807
|
+
permission: { "*": "allow", external_directory: "deny" },
|
|
2808
|
+
});
|
|
2809
|
+
|
|
2810
|
+
try {
|
|
2811
|
+
const sessionRules = [
|
|
2812
|
+
{
|
|
2813
|
+
surface: "external_directory",
|
|
2814
|
+
pattern: "/other/project/*",
|
|
2815
|
+
action: "allow" as const,
|
|
2816
|
+
layer: "session" as const,
|
|
2817
|
+
origin: "session" as const,
|
|
2818
|
+
},
|
|
2819
|
+
];
|
|
2820
|
+
|
|
2821
|
+
const result = manager.checkPermission(
|
|
2822
|
+
"external_directory",
|
|
2823
|
+
{ path: "/other/project/src/foo.ts" },
|
|
2824
|
+
undefined,
|
|
2825
|
+
sessionRules,
|
|
2826
|
+
);
|
|
2827
|
+
expect(result.state).toBe("allow");
|
|
2828
|
+
expect(result.source).toBe("session");
|
|
2829
|
+
} finally {
|
|
2830
|
+
cleanup();
|
|
2831
|
+
}
|
|
2832
|
+
});
|
|
2833
|
+
|
|
2834
|
+
test("checkPermission returns source 'session' for bash when session rules match", () => {
|
|
2835
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2836
|
+
|
|
2837
|
+
try {
|
|
2838
|
+
const sessionRules = [
|
|
2839
|
+
{
|
|
2840
|
+
surface: "bash",
|
|
2841
|
+
pattern: "git *",
|
|
2842
|
+
action: "allow" as const,
|
|
2843
|
+
layer: "session" as const,
|
|
2844
|
+
origin: "session" as const,
|
|
2845
|
+
},
|
|
2846
|
+
];
|
|
2847
|
+
|
|
2848
|
+
const result = manager.checkPermission(
|
|
2849
|
+
"bash",
|
|
2850
|
+
{ command: "git status --short" },
|
|
2851
|
+
undefined,
|
|
2852
|
+
sessionRules,
|
|
2853
|
+
);
|
|
2854
|
+
expect(result.state).toBe("allow");
|
|
2855
|
+
expect(result.source).toBe("session");
|
|
2856
|
+
expect(result.matchedPattern).toBe("git *");
|
|
2857
|
+
} finally {
|
|
2858
|
+
cleanup();
|
|
2859
|
+
}
|
|
2860
|
+
});
|
|
2861
|
+
|
|
2862
|
+
test("checkPermission returns source 'session' for bash when session rule is exact match", () => {
|
|
2863
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2864
|
+
|
|
2865
|
+
try {
|
|
2866
|
+
const sessionRules = [
|
|
2867
|
+
{
|
|
2868
|
+
surface: "bash",
|
|
2869
|
+
pattern: "ls",
|
|
2870
|
+
action: "allow" as const,
|
|
2871
|
+
layer: "session" as const,
|
|
2872
|
+
origin: "session" as const,
|
|
2873
|
+
},
|
|
2874
|
+
];
|
|
2875
|
+
|
|
2876
|
+
const result = manager.checkPermission(
|
|
2877
|
+
"bash",
|
|
2878
|
+
{ command: "ls" },
|
|
2879
|
+
undefined,
|
|
2880
|
+
sessionRules,
|
|
2881
|
+
);
|
|
2882
|
+
expect(result.state).toBe("allow");
|
|
2883
|
+
expect(result.source).toBe("session");
|
|
2884
|
+
} finally {
|
|
2885
|
+
cleanup();
|
|
2886
|
+
}
|
|
2887
|
+
});
|
|
2888
|
+
|
|
2889
|
+
test("checkPermission falls back to config for bash when session rules do not match the command", () => {
|
|
2890
|
+
const { manager, cleanup } = createManager({ permission: { bash: "deny" } });
|
|
2891
|
+
|
|
2892
|
+
try {
|
|
2893
|
+
const sessionRules = [
|
|
2894
|
+
{
|
|
2895
|
+
surface: "bash",
|
|
2896
|
+
pattern: "git *",
|
|
2897
|
+
action: "allow" as const,
|
|
2898
|
+
layer: "session" as const,
|
|
2899
|
+
origin: "session" as const,
|
|
2900
|
+
},
|
|
2901
|
+
];
|
|
2902
|
+
|
|
2903
|
+
const result = manager.checkPermission(
|
|
2904
|
+
"bash",
|
|
2905
|
+
{ command: "npm run build" },
|
|
2906
|
+
undefined,
|
|
2907
|
+
sessionRules,
|
|
2908
|
+
);
|
|
2909
|
+
expect(result.state).toBe("deny");
|
|
2910
|
+
expect(result.source).toBe("bash");
|
|
2911
|
+
} finally {
|
|
2912
|
+
cleanup();
|
|
2913
|
+
}
|
|
2914
|
+
});
|
|
2915
|
+
|
|
2916
|
+
test("checkPermission returns source 'session' for mcp when session rules match the target", () => {
|
|
2917
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2918
|
+
|
|
2919
|
+
try {
|
|
2920
|
+
const sessionRules = [
|
|
2921
|
+
{
|
|
2922
|
+
surface: "mcp",
|
|
2923
|
+
pattern: "exa:*",
|
|
2924
|
+
action: "allow" as const,
|
|
2925
|
+
layer: "session" as const,
|
|
2926
|
+
origin: "session" as const,
|
|
2927
|
+
},
|
|
2928
|
+
];
|
|
2929
|
+
|
|
2930
|
+
const result = manager.checkPermission(
|
|
2931
|
+
"mcp",
|
|
2932
|
+
{ tool: "exa:search" },
|
|
2933
|
+
undefined,
|
|
2934
|
+
sessionRules,
|
|
2935
|
+
);
|
|
2936
|
+
expect(result.state).toBe("allow");
|
|
2937
|
+
expect(result.source).toBe("session");
|
|
2938
|
+
} finally {
|
|
2939
|
+
cleanup();
|
|
2940
|
+
}
|
|
2941
|
+
});
|
|
2942
|
+
|
|
2943
|
+
test("checkPermission returns source 'session' for skill when session rules match", () => {
|
|
2944
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2945
|
+
|
|
2946
|
+
try {
|
|
2947
|
+
const sessionRules = [
|
|
2948
|
+
{
|
|
2949
|
+
surface: "skill",
|
|
2950
|
+
pattern: "librarian",
|
|
2951
|
+
action: "allow" as const,
|
|
2952
|
+
layer: "session" as const,
|
|
2953
|
+
origin: "session" as const,
|
|
2954
|
+
},
|
|
2955
|
+
];
|
|
2956
|
+
|
|
2957
|
+
const result = manager.checkPermission(
|
|
2958
|
+
"skill",
|
|
2959
|
+
{ name: "librarian" },
|
|
2960
|
+
undefined,
|
|
2961
|
+
sessionRules,
|
|
2962
|
+
);
|
|
2963
|
+
expect(result.state).toBe("allow");
|
|
2964
|
+
expect(result.source).toBe("session");
|
|
2965
|
+
expect(result.matchedPattern).toBe("librarian");
|
|
2966
|
+
} finally {
|
|
2967
|
+
cleanup();
|
|
2968
|
+
}
|
|
2969
|
+
});
|
|
2970
|
+
|
|
2971
|
+
test("checkPermission returns source 'session' for tool surface when session rules match", () => {
|
|
2972
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2973
|
+
|
|
2974
|
+
try {
|
|
2975
|
+
const sessionRules = [
|
|
2976
|
+
{
|
|
2977
|
+
surface: "read",
|
|
2978
|
+
pattern: "*",
|
|
2979
|
+
action: "allow" as const,
|
|
2980
|
+
layer: "session" as const,
|
|
2981
|
+
origin: "session" as const,
|
|
2982
|
+
},
|
|
2983
|
+
];
|
|
2984
|
+
|
|
2985
|
+
const result = manager.checkPermission("read", {}, undefined, sessionRules);
|
|
2986
|
+
expect(result.state).toBe("allow");
|
|
2987
|
+
expect(result.source).toBe("session");
|
|
2988
|
+
} finally {
|
|
2989
|
+
cleanup();
|
|
2990
|
+
}
|
|
2991
|
+
});
|
|
2992
|
+
|
|
2993
|
+
test("bash session rules do not bleed into mcp checks", () => {
|
|
2994
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2995
|
+
|
|
2996
|
+
try {
|
|
2997
|
+
const sessionRules = [
|
|
2998
|
+
{
|
|
2999
|
+
surface: "bash",
|
|
3000
|
+
pattern: "git *",
|
|
3001
|
+
action: "allow" as const,
|
|
3002
|
+
layer: "session" as const,
|
|
3003
|
+
origin: "session" as const,
|
|
3004
|
+
},
|
|
3005
|
+
];
|
|
3006
|
+
|
|
3007
|
+
const result = manager.checkPermission(
|
|
3008
|
+
"mcp",
|
|
3009
|
+
{ tool: "exa:search" },
|
|
3010
|
+
undefined,
|
|
3011
|
+
sessionRules,
|
|
3012
|
+
);
|
|
3013
|
+
expect(result.source).not.toBe("session");
|
|
3014
|
+
} finally {
|
|
3015
|
+
cleanup();
|
|
3016
|
+
}
|
|
3017
|
+
});
|
|
3018
|
+
|
|
3019
|
+
// ---------------------------------------------------------------------------
|
|
3020
|
+
// getResolvedPolicyPaths — moved from catch-all (#342)
|
|
3021
|
+
// ---------------------------------------------------------------------------
|
|
3022
|
+
|
|
3023
|
+
test("getResolvedPolicyPaths returns correct paths and existence when files exist", () => {
|
|
3024
|
+
const tempDir = mkdtempSync(join(tmpdir(), "policy-paths-exist-"));
|
|
3025
|
+
try {
|
|
3026
|
+
const globalConfigPath = join(tempDir, "pi-permissions.jsonc");
|
|
3027
|
+
const agentsDir = join(tempDir, "agents");
|
|
3028
|
+
const projectConfigPath = join(tempDir, "project", "pi-permissions.jsonc");
|
|
3029
|
+
const projectAgentsDir = join(tempDir, "project", "agents");
|
|
3030
|
+
|
|
3031
|
+
writeFileSync(globalConfigPath, "{}", "utf-8");
|
|
3032
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
3033
|
+
mkdirSync(join(tempDir, "project"), { recursive: true });
|
|
3034
|
+
writeFileSync(projectConfigPath, "{}", "utf-8");
|
|
3035
|
+
mkdirSync(projectAgentsDir, { recursive: true });
|
|
3036
|
+
|
|
3037
|
+
const pm = new PermissionManager({
|
|
3038
|
+
globalConfigPath,
|
|
3039
|
+
agentsDir,
|
|
3040
|
+
projectGlobalConfigPath: projectConfigPath,
|
|
3041
|
+
projectAgentsDir,
|
|
3042
|
+
});
|
|
3043
|
+
|
|
3044
|
+
const result = pm.getResolvedPolicyPaths();
|
|
3045
|
+
|
|
3046
|
+
expect(result.globalConfigPath).toBe(globalConfigPath);
|
|
3047
|
+
expect(result.globalConfigExists).toBe(true);
|
|
3048
|
+
expect(result.projectConfigPath).toBe(projectConfigPath);
|
|
3049
|
+
expect(result.projectConfigExists).toBe(true);
|
|
3050
|
+
expect(result.agentsDir).toBe(agentsDir);
|
|
3051
|
+
expect(result.agentsDirExists).toBe(true);
|
|
3052
|
+
expect(result.projectAgentsDir).toBe(projectAgentsDir);
|
|
3053
|
+
expect(result.projectAgentsDirExists).toBe(true);
|
|
3054
|
+
} finally {
|
|
3055
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
3056
|
+
}
|
|
3057
|
+
});
|
|
3058
|
+
|
|
3059
|
+
test("getResolvedPolicyPaths returns false for missing files and null for absent project paths", () => {
|
|
3060
|
+
const tempDir = mkdtempSync(join(tmpdir(), "policy-paths-missing-"));
|
|
3061
|
+
try {
|
|
3062
|
+
const globalConfigPath = join(tempDir, "does-not-exist.jsonc");
|
|
3063
|
+
const agentsDir = join(tempDir, "no-agents");
|
|
3064
|
+
|
|
3065
|
+
const pm = new PermissionManager({
|
|
3066
|
+
globalConfigPath,
|
|
3067
|
+
agentsDir,
|
|
3068
|
+
});
|
|
3069
|
+
|
|
3070
|
+
const result = pm.getResolvedPolicyPaths();
|
|
3071
|
+
|
|
3072
|
+
expect(result.globalConfigPath).toBe(globalConfigPath);
|
|
3073
|
+
expect(result.globalConfigExists).toBe(false);
|
|
3074
|
+
expect(result.projectConfigPath).toBe(null);
|
|
3075
|
+
expect(result.projectConfigExists).toBe(false);
|
|
3076
|
+
expect(result.agentsDir).toBe(agentsDir);
|
|
3077
|
+
expect(result.agentsDirExists).toBe(false);
|
|
3078
|
+
expect(result.projectAgentsDir).toBe(null);
|
|
3079
|
+
expect(result.projectAgentsDirExists).toBe(false);
|
|
3080
|
+
} finally {
|
|
3081
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
3082
|
+
}
|
|
3083
|
+
});
|