@komarspn/pi-permission-system 16.0.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.
Files changed (203) hide show
  1. package/CHANGELOG.md +2234 -0
  2. package/LICENSE +21 -0
  3. package/README.md +158 -0
  4. package/config/config.example.json +39 -0
  5. package/package.json +82 -0
  6. package/schemas/permissions.schema.json +158 -0
  7. package/src/active-agent.ts +72 -0
  8. package/src/async-cache.ts +21 -0
  9. package/src/bash-arity.ts +210 -0
  10. package/src/builtin-tool-input-formatters.ts +82 -0
  11. package/src/canonicalize-path.ts +30 -0
  12. package/src/common.ts +121 -0
  13. package/src/config-loader.ts +432 -0
  14. package/src/config-modal.ts +259 -0
  15. package/src/config-paths.ts +47 -0
  16. package/src/config-reporter.ts +34 -0
  17. package/src/config-store.ts +222 -0
  18. package/src/decision-audit.ts +75 -0
  19. package/src/decision-reporter.ts +41 -0
  20. package/src/denial-messages.ts +232 -0
  21. package/src/expand-home.ts +28 -0
  22. package/src/extension-config.ts +79 -0
  23. package/src/extension-paths.ts +66 -0
  24. package/src/forwarded-permissions/io.ts +404 -0
  25. package/src/forwarded-permissions/permission-forwarder.ts +580 -0
  26. package/src/forwarding-manager.ts +74 -0
  27. package/src/gate-prompter.ts +12 -0
  28. package/src/handlers/before-agent-start.ts +94 -0
  29. package/src/handlers/gates/bash-command.ts +75 -0
  30. package/src/handlers/gates/bash-external-directory.ts +127 -0
  31. package/src/handlers/gates/bash-path-extractor.ts +15 -0
  32. package/src/handlers/gates/bash-path.ts +152 -0
  33. package/src/handlers/gates/bash-program.ts +1143 -0
  34. package/src/handlers/gates/bash-token-classification.ts +105 -0
  35. package/src/handlers/gates/candidate-check.ts +32 -0
  36. package/src/handlers/gates/descriptor.ts +81 -0
  37. package/src/handlers/gates/external-directory-messages.ts +20 -0
  38. package/src/handlers/gates/external-directory.ts +133 -0
  39. package/src/handlers/gates/helpers.ts +76 -0
  40. package/src/handlers/gates/path.ts +91 -0
  41. package/src/handlers/gates/runner.ts +186 -0
  42. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  43. package/src/handlers/gates/skill-input.ts +46 -0
  44. package/src/handlers/gates/skill-read.ts +87 -0
  45. package/src/handlers/gates/tool-call-gate-pipeline.ts +129 -0
  46. package/src/handlers/gates/tool.ts +102 -0
  47. package/src/handlers/gates/types.ts +13 -0
  48. package/src/handlers/index.ts +3 -0
  49. package/src/handlers/lifecycle.ts +95 -0
  50. package/src/handlers/permission-gate-handler.ts +190 -0
  51. package/src/handlers/tool-call-boundary.ts +91 -0
  52. package/src/index.ts +225 -0
  53. package/src/input-normalizer.ts +157 -0
  54. package/src/logging.ts +113 -0
  55. package/src/mcp-targets.ts +170 -0
  56. package/src/node-modules-discovery.ts +76 -0
  57. package/src/normalize.ts +43 -0
  58. package/src/path-utils.ts +355 -0
  59. package/src/pattern-suggest.ts +132 -0
  60. package/src/permission-dialog.ts +138 -0
  61. package/src/permission-event-rpc.ts +223 -0
  62. package/src/permission-events.ts +266 -0
  63. package/src/permission-forwarding.ts +188 -0
  64. package/src/permission-gate.ts +94 -0
  65. package/src/permission-manager.ts +392 -0
  66. package/src/permission-merge.ts +32 -0
  67. package/src/permission-prompter.ts +142 -0
  68. package/src/permission-prompts.ts +93 -0
  69. package/src/permission-resolver.ts +109 -0
  70. package/src/permission-session.ts +189 -0
  71. package/src/permission-ui-prompt.ts +127 -0
  72. package/src/permissions-service.ts +63 -0
  73. package/src/persistent-approval-recorder.ts +139 -0
  74. package/src/policy-loader.ts +350 -0
  75. package/src/prompting-gateway.ts +104 -0
  76. package/src/rule.ts +188 -0
  77. package/src/scope-merge.ts +72 -0
  78. package/src/service-lifecycle.ts +49 -0
  79. package/src/service.ts +163 -0
  80. package/src/session-approval-recorder.ts +6 -0
  81. package/src/session-approval.ts +43 -0
  82. package/src/session-logger.ts +91 -0
  83. package/src/session-rules.ts +79 -0
  84. package/src/skill-prompt-sanitizer.ts +292 -0
  85. package/src/status.ts +35 -0
  86. package/src/subagent-context.ts +104 -0
  87. package/src/subagent-lifecycle-events.ts +72 -0
  88. package/src/subagent-registry.ts +105 -0
  89. package/src/synthesize.ts +92 -0
  90. package/src/system-prompt-sanitizer.ts +274 -0
  91. package/src/tool-access-extractor-registry.ts +68 -0
  92. package/src/tool-input-formatter-registry.ts +67 -0
  93. package/src/tool-input-preview.ts +34 -0
  94. package/src/tool-input-prompt-formatters.ts +63 -0
  95. package/src/tool-preview-formatter.ts +207 -0
  96. package/src/tool-registry.ts +148 -0
  97. package/src/types.ts +64 -0
  98. package/src/wildcard-matcher.ts +120 -0
  99. package/src/yolo-mode.ts +30 -0
  100. package/test/active-agent.test.ts +155 -0
  101. package/test/async-cache.test.ts +48 -0
  102. package/test/bash-arity.test.ts +144 -0
  103. package/test/bash-external-directory.test.ts +956 -0
  104. package/test/builtin-tool-input-formatters.test.ts +109 -0
  105. package/test/canonicalize-path.test.ts +93 -0
  106. package/test/common.test.ts +287 -0
  107. package/test/composition-root.test.ts +603 -0
  108. package/test/config-loader.test.ts +740 -0
  109. package/test/config-modal.test.ts +320 -0
  110. package/test/config-paths.test.ts +83 -0
  111. package/test/config-pipeline.test.ts +90 -0
  112. package/test/config-reporter.test.ts +147 -0
  113. package/test/config-store.test.ts +466 -0
  114. package/test/decision-audit.test.ts +72 -0
  115. package/test/decision-reporter.test.ts +112 -0
  116. package/test/denial-messages.test.ts +656 -0
  117. package/test/detect-permissive-bash-fallback.test.ts +56 -0
  118. package/test/expand-home.test.ts +93 -0
  119. package/test/extension-config.test.ts +129 -0
  120. package/test/extension-paths.test.ts +108 -0
  121. package/test/forwarded-permissions/io.test.ts +251 -0
  122. package/test/forwarding-manager.test.ts +194 -0
  123. package/test/handlers/before-agent-start.test.ts +317 -0
  124. package/test/handlers/external-directory-integration.test.ts +623 -0
  125. package/test/handlers/external-directory-session-dedup.test.ts +430 -0
  126. package/test/handlers/external-directory-symlink-acceptance.test.ts +149 -0
  127. package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
  128. package/test/handlers/gates/bash-command.test.ts +191 -0
  129. package/test/handlers/gates/bash-external-directory.test.ts +269 -0
  130. package/test/handlers/gates/bash-path.test.ts +337 -0
  131. package/test/handlers/gates/bash-program.test.ts +410 -0
  132. package/test/handlers/gates/bash-token-classification.test.ts +241 -0
  133. package/test/handlers/gates/candidate-check.test.ts +52 -0
  134. package/test/handlers/gates/external-directory-messages.test.ts +61 -0
  135. package/test/handlers/gates/external-directory.test.ts +259 -0
  136. package/test/handlers/gates/helpers.test.ts +177 -0
  137. package/test/handlers/gates/path.test.ts +294 -0
  138. package/test/handlers/gates/runner.test.ts +447 -0
  139. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  140. package/test/handlers/gates/skill-input.test.ts +131 -0
  141. package/test/handlers/gates/skill-read.test.ts +158 -0
  142. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +252 -0
  143. package/test/handlers/gates/tool.test.ts +223 -0
  144. package/test/handlers/input-events.test.ts +168 -0
  145. package/test/handlers/input.test.ts +199 -0
  146. package/test/handlers/lifecycle.test.ts +221 -0
  147. package/test/handlers/tool-call-boundary.test.ts +145 -0
  148. package/test/handlers/tool-call-events.test.ts +277 -0
  149. package/test/handlers/tool-call.test.ts +395 -0
  150. package/test/handlers/validate-requested-tool.test.ts +92 -0
  151. package/test/helpers/gate-fixtures.ts +323 -0
  152. package/test/helpers/handler-fixtures.ts +335 -0
  153. package/test/helpers/make-fake-pi.ts +100 -0
  154. package/test/helpers/manager-harness.ts +112 -0
  155. package/test/helpers/session-fixtures.ts +204 -0
  156. package/test/input-normalizer.test.ts +367 -0
  157. package/test/logging.test.ts +51 -0
  158. package/test/mcp-targets.test.ts +233 -0
  159. package/test/node-modules-discovery.test.ts +97 -0
  160. package/test/normalize.test.ts +247 -0
  161. package/test/path-utils.test.ts +650 -0
  162. package/test/pattern-suggest.test.ts +248 -0
  163. package/test/permission-dialog.test.ts +241 -0
  164. package/test/permission-event-rpc.test.ts +541 -0
  165. package/test/permission-events.test.ts +402 -0
  166. package/test/permission-forwarder.test.ts +369 -0
  167. package/test/permission-forwarding.test.ts +315 -0
  168. package/test/permission-gate.test.ts +305 -0
  169. package/test/permission-manager-unified.test.ts +3368 -0
  170. package/test/permission-merge.test.ts +61 -0
  171. package/test/permission-prompter.test.ts +518 -0
  172. package/test/permission-prompts.test.ts +363 -0
  173. package/test/permission-resolver.test.ts +265 -0
  174. package/test/permission-session.test.ts +363 -0
  175. package/test/permission-ui-prompt.test.ts +146 -0
  176. package/test/permissions-service.test.ts +177 -0
  177. package/test/persistent-approval-recorder.test.ts +133 -0
  178. package/test/pi-infrastructure-read.test.ts +369 -0
  179. package/test/policy-loader.test.ts +561 -0
  180. package/test/prompting-gateway.test.ts +230 -0
  181. package/test/rule.test.ts +604 -0
  182. package/test/scope-merge.test.ts +116 -0
  183. package/test/service-lifecycle.test.ts +163 -0
  184. package/test/service.test.ts +308 -0
  185. package/test/session-approval.test.ts +75 -0
  186. package/test/session-logger.test.ts +200 -0
  187. package/test/session-rules.test.ts +304 -0
  188. package/test/session-start.test.ts +112 -0
  189. package/test/skill-prompt-sanitizer.test.ts +374 -0
  190. package/test/status.test.ts +10 -0
  191. package/test/subagent-context.test.ts +326 -0
  192. package/test/subagent-lifecycle-events.test.ts +132 -0
  193. package/test/subagent-registry.test.ts +145 -0
  194. package/test/synthesize.test.ts +300 -0
  195. package/test/system-prompt-sanitizer.test.ts +382 -0
  196. package/test/tool-access-extractor-registry.test.ts +77 -0
  197. package/test/tool-input-formatter-registry.test.ts +75 -0
  198. package/test/tool-input-preview.test.ts +129 -0
  199. package/test/tool-input-prompt-formatters.test.ts +115 -0
  200. package/test/tool-preview-formatter.test.ts +458 -0
  201. package/test/tool-registry.test.ts +197 -0
  202. package/test/wildcard-matcher.test.ts +424 -0
  203. package/test/yolo-mode.test.ts +188 -0
@@ -0,0 +1,133 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { PersistentApprovalRecorder } from "#src/persistent-approval-recorder";
6
+ import { SessionApproval } from "#src/session-approval";
7
+ import type { SessionLogger } from "#src/session-logger";
8
+
9
+ function makeLogger(): SessionLogger {
10
+ return {
11
+ debug: vi.fn(),
12
+ review: vi.fn(),
13
+ warn: vi.fn(),
14
+ };
15
+ }
16
+
17
+ describe("PersistentApprovalRecorder", () => {
18
+ it("writes project allow rule to project config", () => {
19
+ const root = mkdtempSync(join(tmpdir(), "pi-permission-project-"));
20
+ const agentDir = join(root, "agent");
21
+ const cwd = join(root, "project");
22
+ const logger = makeLogger();
23
+ const recorder = new PersistentApprovalRecorder({
24
+ agentDir,
25
+ getCwd: () => cwd,
26
+ logger,
27
+ });
28
+
29
+ recorder.recordApproval(
30
+ "project",
31
+ SessionApproval.single("bash", "git status"),
32
+ );
33
+
34
+ const config = JSON.parse(
35
+ readFileSync(
36
+ join(cwd, ".pi", "extensions", "pi-permission-system", "config.json"),
37
+ "utf-8",
38
+ ),
39
+ ) as Record<string, unknown>;
40
+ expect(config).toEqual({
41
+ permission: {
42
+ bash: {
43
+ "git status": "allow",
44
+ },
45
+ },
46
+ });
47
+ });
48
+
49
+ it("writes global allow rule to global config", () => {
50
+ const root = mkdtempSync(join(tmpdir(), "pi-permission-global-"));
51
+ const agentDir = join(root, "agent");
52
+ const logger = makeLogger();
53
+ const recorder = new PersistentApprovalRecorder({
54
+ agentDir,
55
+ getCwd: () => null,
56
+ logger,
57
+ });
58
+
59
+ recorder.recordApproval(
60
+ "global",
61
+ SessionApproval.single("mcp", "github:create_issue"),
62
+ );
63
+
64
+ const config = JSON.parse(
65
+ readFileSync(
66
+ join(agentDir, "extensions", "pi-permission-system", "config.json"),
67
+ "utf-8",
68
+ ),
69
+ ) as Record<string, unknown>;
70
+ expect(config).toEqual({
71
+ permission: {
72
+ mcp: {
73
+ "github:create_issue": "allow",
74
+ },
75
+ },
76
+ });
77
+ });
78
+
79
+ it("preserves existing permission entries while adding allow patterns", () => {
80
+ const root = mkdtempSync(join(tmpdir(), "pi-permission-merge-"));
81
+ const agentDir = join(root, "agent");
82
+ const configPath = join(
83
+ agentDir,
84
+ "extensions",
85
+ "pi-permission-system",
86
+ "config.json",
87
+ );
88
+ mkdirSync(join(agentDir, "extensions", "pi-permission-system"), {
89
+ recursive: true,
90
+ });
91
+ writeFileSync(
92
+ configPath,
93
+ JSON.stringify(
94
+ {
95
+ debugLog: true,
96
+ permission: {
97
+ bash: {
98
+ "rm *": "deny",
99
+ },
100
+ },
101
+ },
102
+ null,
103
+ 2,
104
+ ),
105
+ );
106
+ const logger = makeLogger();
107
+ const recorder = new PersistentApprovalRecorder({
108
+ agentDir,
109
+ getCwd: () => null,
110
+ logger,
111
+ });
112
+
113
+ recorder.recordApproval(
114
+ "global",
115
+ SessionApproval.multiple("bash", ["git status", "git log"]),
116
+ );
117
+
118
+ const config = JSON.parse(readFileSync(configPath, "utf-8")) as Record<
119
+ string,
120
+ unknown
121
+ >;
122
+ expect(config).toEqual({
123
+ debugLog: true,
124
+ permission: {
125
+ bash: {
126
+ "rm *": "deny",
127
+ "git status": "allow",
128
+ "git log": "allow",
129
+ },
130
+ },
131
+ });
132
+ });
133
+ });
@@ -0,0 +1,369 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { beforeEach, describe, expect, test, vi } from "vitest";
4
+
5
+ // Hoisted stub so the vi.mock factory can reference it.
6
+ const { mockSpawnSync } = vi.hoisted(() => ({
7
+ mockSpawnSync: vi.fn(),
8
+ }));
9
+
10
+ // Mock node:child_process so tests that exercise the subprocess fallback path
11
+ // don't actually invoke npm. Default: subprocess fails (non-zero exit), so
12
+ // tests focused on the walk-up strategy continue to expect null.
13
+ vi.mock("node:child_process", () => ({
14
+ spawnSync: mockSpawnSync,
15
+ default: { spawnSync: mockSpawnSync },
16
+ }));
17
+
18
+ import { discoverGlobalNodeModulesRoot } from "#src/node-modules-discovery";
19
+ import { isPiInfrastructureRead } from "#src/path-utils";
20
+
21
+ // ── discoverGlobalNodeModulesRoot ──────────────────────────────────────────
22
+
23
+ describe("discoverGlobalNodeModulesRoot", () => {
24
+ beforeEach(() => {
25
+ // Default: subprocess fails, so walk-up-focused tests see null for URLs
26
+ // with no node_modules ancestor.
27
+ mockSpawnSync.mockReset();
28
+ mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
29
+ });
30
+
31
+ test("returns the node_modules dir when the file is inside one", () => {
32
+ const url =
33
+ "file:///opt/homebrew/lib/node_modules/pi-permission-system/dist/external-directory.js";
34
+ expect(discoverGlobalNodeModulesRoot(url)).toBe(
35
+ "/opt/homebrew/lib/node_modules",
36
+ );
37
+ });
38
+
39
+ test("returns node_modules for a deeply nested file", () => {
40
+ const url =
41
+ "file:///home/user/.nvm/versions/node/v20/lib/node_modules/pi-permission-system/src/external-directory.js";
42
+ expect(discoverGlobalNodeModulesRoot(url)).toBe(
43
+ "/home/user/.nvm/versions/node/v20/lib/node_modules",
44
+ );
45
+ });
46
+
47
+ test("returns node_modules for a bun global install path", () => {
48
+ const url =
49
+ "file:///home/user/.bun/install/global/node_modules/pi-permission-system/dist/external-directory.js";
50
+ expect(discoverGlobalNodeModulesRoot(url)).toBe(
51
+ "/home/user/.bun/install/global/node_modules",
52
+ );
53
+ });
54
+
55
+ test("returns the innermost (closest-to-file) node_modules ancestor", () => {
56
+ // The walk-up algorithm stops at the first node_modules dir it encounters,
57
+ // which is the innermost one when the file is inside a nested install.
58
+ // In practice this never happens for a real global install — the extension
59
+ // is always directly at <global_root>/node_modules/pi-permission-system/…
60
+ const url =
61
+ "file:///opt/lib/node_modules/some-pkg/node_modules/pi-permission-system/dist/index.js";
62
+ expect(discoverGlobalNodeModulesRoot(url)).toBe(
63
+ "/opt/lib/node_modules/some-pkg/node_modules",
64
+ );
65
+ });
66
+
67
+ test("returns null when the file is not inside any node_modules directory", () => {
68
+ const url =
69
+ "file:///home/user/development/pi-permission-system/dist/external-directory.js";
70
+ expect(discoverGlobalNodeModulesRoot(url)).toBeNull();
71
+ });
72
+
73
+ test("returns null for a root-level file", () => {
74
+ const url = "file:///external-directory.js";
75
+ expect(discoverGlobalNodeModulesRoot(url)).toBeNull();
76
+ });
77
+
78
+ test("returns null for an invalid URL", () => {
79
+ expect(discoverGlobalNodeModulesRoot("not-a-url")).toBeNull();
80
+ });
81
+
82
+ test("works with the real import.meta.url of this extension (smoke test)", () => {
83
+ // The extension IS installed inside a node_modules tree when running in CI
84
+ // or global install. In a local dev checkout the result may be null — that's
85
+ // the documented graceful-degradation path.
86
+ const result = discoverGlobalNodeModulesRoot();
87
+ expect(result === null || result.endsWith("node_modules")).toBe(true);
88
+ });
89
+
90
+ test("the discovered path includes the pi-permission-system package directory", () => {
91
+ const url =
92
+ "file:///opt/homebrew/lib/node_modules/pi-permission-system/dist/external-directory.js";
93
+ const root = discoverGlobalNodeModulesRoot(url);
94
+ expect(root).not.toBeNull();
95
+ expect(join(root!, "pi-permission-system")).toBe(
96
+ "/opt/homebrew/lib/node_modules/pi-permission-system",
97
+ );
98
+ });
99
+ });
100
+
101
+ // ── isPiInfrastructureRead ─────────────────────────────────────────────────
102
+
103
+ const INFRA_DIRS = [
104
+ "/home/user/.pi/agent",
105
+ "/home/user/.pi/agent/git",
106
+ "/opt/homebrew/lib/node_modules",
107
+ ];
108
+ const CWD = "/home/user/project";
109
+
110
+ describe("isPiInfrastructureRead", () => {
111
+ // ── read tools allowed for infra paths ──────────────────────────────────
112
+
113
+ test("allows 'read' tool for a file inside agentDir", () => {
114
+ expect(
115
+ isPiInfrastructureRead(
116
+ "read",
117
+ "/home/user/.pi/agent/extensions/pi-permission-system/config.json",
118
+ INFRA_DIRS,
119
+ CWD,
120
+ ),
121
+ ).toBe(true);
122
+ });
123
+
124
+ test("allows 'find' tool for a path inside node_modules infra dir", () => {
125
+ expect(
126
+ isPiInfrastructureRead(
127
+ "find",
128
+ "/opt/homebrew/lib/node_modules/pi-ask-user/skills",
129
+ INFRA_DIRS,
130
+ CWD,
131
+ ),
132
+ ).toBe(true);
133
+ });
134
+
135
+ test("allows 'grep' tool for a path inside agentDir/git", () => {
136
+ expect(
137
+ isPiInfrastructureRead(
138
+ "grep",
139
+ "/home/user/.pi/agent/git/some-package/README.md",
140
+ INFRA_DIRS,
141
+ CWD,
142
+ ),
143
+ ).toBe(true);
144
+ });
145
+
146
+ test("allows 'ls' tool for a path inside node_modules infra dir", () => {
147
+ expect(
148
+ isPiInfrastructureRead(
149
+ "ls",
150
+ "/opt/homebrew/lib/node_modules/pi-permission-system",
151
+ INFRA_DIRS,
152
+ CWD,
153
+ ),
154
+ ).toBe(true);
155
+ });
156
+
157
+ // ── write tools never allowed even for infra paths ───────────────────────
158
+
159
+ test("blocks 'write' tool for a file inside agentDir", () => {
160
+ expect(
161
+ isPiInfrastructureRead(
162
+ "write",
163
+ "/home/user/.pi/agent/extensions/pi-permission-system/config.json",
164
+ INFRA_DIRS,
165
+ CWD,
166
+ ),
167
+ ).toBe(false);
168
+ });
169
+
170
+ test("blocks 'edit' tool for a file inside node_modules", () => {
171
+ expect(
172
+ isPiInfrastructureRead(
173
+ "edit",
174
+ "/opt/homebrew/lib/node_modules/pi-ask-user/skills/ask-user/SKILL.md",
175
+ INFRA_DIRS,
176
+ CWD,
177
+ ),
178
+ ).toBe(false);
179
+ });
180
+
181
+ test("blocks 'bash' tool regardless of path", () => {
182
+ expect(
183
+ isPiInfrastructureRead(
184
+ "bash",
185
+ "/opt/homebrew/lib/node_modules/pi-ask-user/SKILL.md",
186
+ INFRA_DIRS,
187
+ CWD,
188
+ ),
189
+ ).toBe(false);
190
+ });
191
+
192
+ // ── non-infra paths not allowed ──────────────────────────────────────────
193
+
194
+ test("does not allow 'read' for a path outside all infra dirs", () => {
195
+ expect(isPiInfrastructureRead("read", "/etc/passwd", INFRA_DIRS, CWD)).toBe(
196
+ false,
197
+ );
198
+ });
199
+
200
+ test("does not allow 'read' for a path only partially matching an infra dir prefix", () => {
201
+ // /home/user/.pi/agent-other should not match /home/user/.pi/agent
202
+ expect(
203
+ isPiInfrastructureRead(
204
+ "read",
205
+ "/home/user/.pi/agent-other/config.json",
206
+ INFRA_DIRS,
207
+ CWD,
208
+ ),
209
+ ).toBe(false);
210
+ });
211
+
212
+ // ── project-local Pi packages (.pi/npm, .pi/git) ─────────────────────────
213
+
214
+ test("allows 'read' for a path inside project-local .pi/npm/", () => {
215
+ expect(
216
+ isPiInfrastructureRead(
217
+ "read",
218
+ `${CWD}/.pi/npm/node_modules/some-skill/SKILL.md`,
219
+ INFRA_DIRS,
220
+ CWD,
221
+ ),
222
+ ).toBe(true);
223
+ });
224
+
225
+ test("allows 'read' for a path inside project-local .pi/git/", () => {
226
+ expect(
227
+ isPiInfrastructureRead(
228
+ "read",
229
+ `${CWD}/.pi/git/github.com/org/skill-repo/SKILL.md`,
230
+ INFRA_DIRS,
231
+ CWD,
232
+ ),
233
+ ).toBe(true);
234
+ });
235
+
236
+ test("blocks 'write' for a path inside project-local .pi/npm/", () => {
237
+ expect(
238
+ isPiInfrastructureRead(
239
+ "write",
240
+ `${CWD}/.pi/npm/node_modules/some-skill/SKILL.md`,
241
+ INFRA_DIRS,
242
+ CWD,
243
+ ),
244
+ ).toBe(false);
245
+ });
246
+
247
+ // ── empty / edge cases ───────────────────────────────────────────────────
248
+
249
+ test("returns false when infrastructureDirs is empty and path is not project-local", () => {
250
+ expect(isPiInfrastructureRead("read", "/etc/passwd", [], CWD)).toBe(false);
251
+ });
252
+
253
+ test("returns false when infrastructureDirs is empty but path IS project-local .pi/npm", () => {
254
+ // Project-local paths are checked separately from the dirs array.
255
+ expect(
256
+ isPiInfrastructureRead(
257
+ "read",
258
+ `${CWD}/.pi/npm/node_modules/x/SKILL.md`,
259
+ [],
260
+ CWD,
261
+ ),
262
+ ).toBe(true);
263
+ });
264
+ });
265
+
266
+ // ── isPiInfrastructureRead — glob patterns ─────────────────────────────────
267
+
268
+ describe("isPiInfrastructureRead with glob patterns", () => {
269
+ test("glob entry matches a versioned nested path", () => {
270
+ expect(
271
+ isPiInfrastructureRead(
272
+ "read",
273
+ "/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
274
+ ["/opt/homebrew/*/@earendil-works/pi-coding-agent/*"],
275
+ CWD,
276
+ ),
277
+ ).toBe(true);
278
+ });
279
+
280
+ test("** behaves the same as * (matches across path separators)", () => {
281
+ expect(
282
+ isPiInfrastructureRead(
283
+ "read",
284
+ "/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
285
+ ["/opt/homebrew/**/@earendil-works/pi-coding-agent/**"],
286
+ CWD,
287
+ ),
288
+ ).toBe(true);
289
+ });
290
+
291
+ test("glob entry does not match an unrelated path", () => {
292
+ expect(
293
+ isPiInfrastructureRead(
294
+ "read",
295
+ "/etc/passwd",
296
+ ["/opt/homebrew/*/@earendil-works/pi-coding-agent/*"],
297
+ CWD,
298
+ ),
299
+ ).toBe(false);
300
+ });
301
+
302
+ test("? matches exactly one character", () => {
303
+ expect(
304
+ isPiInfrastructureRead(
305
+ "read",
306
+ "/opt/homebrew/X/file.md",
307
+ ["/opt/homebrew/?/file.md"],
308
+ CWD,
309
+ ),
310
+ ).toBe(true);
311
+ });
312
+
313
+ test("? does not match multiple characters", () => {
314
+ expect(
315
+ isPiInfrastructureRead(
316
+ "read",
317
+ "/opt/homebrew/abc/file.md",
318
+ ["/opt/homebrew/?/file.md"],
319
+ CWD,
320
+ ),
321
+ ).toBe(false);
322
+ });
323
+
324
+ test("mixed array of plain dirs and glob patterns — both branches work", () => {
325
+ const dirs = [
326
+ "/home/user/.pi/agent",
327
+ "/opt/homebrew/*/@earendil-works/pi-coding-agent/*",
328
+ ];
329
+ expect(
330
+ isPiInfrastructureRead(
331
+ "read",
332
+ "/home/user/.pi/agent/config.json",
333
+ dirs,
334
+ CWD,
335
+ ),
336
+ ).toBe(true);
337
+ expect(
338
+ isPiInfrastructureRead(
339
+ "read",
340
+ "/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
341
+ dirs,
342
+ CWD,
343
+ ),
344
+ ).toBe(true);
345
+ });
346
+
347
+ test("plain entry with ~ prefix matches after home expansion", () => {
348
+ const home = homedir();
349
+ expect(
350
+ isPiInfrastructureRead(
351
+ "read",
352
+ `${home}/.pi/agent/config.json`,
353
+ ["~/.pi/agent"],
354
+ CWD,
355
+ ),
356
+ ).toBe(true);
357
+ });
358
+
359
+ test("write tool with a glob-matching path is still rejected", () => {
360
+ expect(
361
+ isPiInfrastructureRead(
362
+ "write",
363
+ "/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
364
+ ["/opt/homebrew/**/@earendil-works/pi-coding-agent/**"],
365
+ CWD,
366
+ ),
367
+ ).toBe(false);
368
+ });
369
+ });