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