@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,650 @@
1
+ import { join } from "node:path";
2
+ import { beforeEach, describe, expect, test, vi } from "vitest";
3
+
4
+ // Mock node:os so tilde-expansion is deterministic across platforms.
5
+ vi.mock("node:os", () => {
6
+ const homedir = vi.fn(() => "/mock/home");
7
+ return {
8
+ homedir,
9
+ default: { homedir },
10
+ };
11
+ });
12
+
13
+ // Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
14
+ // Default implementation is identity — existing lexical tests are unaffected.
15
+ const realpathSync = vi.hoisted(() =>
16
+ vi.fn<(path: string) => string>((p) => p),
17
+ );
18
+ vi.mock("node:fs", () => ({
19
+ realpathSync,
20
+ default: { realpathSync },
21
+ }));
22
+
23
+ import {
24
+ canonicalNormalizePathForComparison,
25
+ getExternalDirectoryPolicyValues,
26
+ getPathBearingToolPath,
27
+ getPathPolicyValues,
28
+ getToolInputPath,
29
+ isPathOutsideWorkingDirectory,
30
+ isPathWithinDirectory,
31
+ isPiInfrastructureRead,
32
+ isSafeSystemPath,
33
+ normalizePathForComparison,
34
+ normalizePathPolicyLiteral,
35
+ PATH_BEARING_TOOLS,
36
+ READ_ONLY_PATH_BEARING_TOOLS,
37
+ SAFE_SYSTEM_PATHS,
38
+ } from "#src/path-utils";
39
+ import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
40
+
41
+ describe("normalizePathForComparison", () => {
42
+ const cwd = "/projects/my-app";
43
+
44
+ test("resolves absolute path unchanged", () => {
45
+ expect(normalizePathForComparison("/usr/local/bin", cwd)).toBe(
46
+ "/usr/local/bin",
47
+ );
48
+ });
49
+
50
+ test("resolves relative path against cwd", () => {
51
+ expect(normalizePathForComparison("src/foo.ts", cwd)).toBe(
52
+ "/projects/my-app/src/foo.ts",
53
+ );
54
+ });
55
+
56
+ test("expands bare ~ to homedir", () => {
57
+ expect(normalizePathForComparison("~", cwd)).toBe("/mock/home");
58
+ });
59
+
60
+ test("expands ~/... to homedir-relative path", () => {
61
+ expect(normalizePathForComparison("~/docs/readme.md", cwd)).toBe(
62
+ join("/mock/home", "docs/readme.md"),
63
+ );
64
+ });
65
+
66
+ test("expands bare $HOME to homedir", () => {
67
+ expect(normalizePathForComparison("$HOME", cwd)).toBe("/mock/home");
68
+ });
69
+
70
+ test("expands $HOME/... to homedir-relative path", () => {
71
+ expect(normalizePathForComparison("$HOME/.ssh/config", cwd)).toBe(
72
+ join("/mock/home", ".ssh/config"),
73
+ );
74
+ });
75
+
76
+ test("strips leading @ before resolving", () => {
77
+ expect(normalizePathForComparison("@/usr/local/bin", cwd)).toBe(
78
+ "/usr/local/bin",
79
+ );
80
+ });
81
+
82
+ test("strips surrounding quotes", () => {
83
+ expect(normalizePathForComparison("'/usr/local/bin'", cwd)).toBe(
84
+ "/usr/local/bin",
85
+ );
86
+ expect(normalizePathForComparison('"/usr/local/bin"', cwd)).toBe(
87
+ "/usr/local/bin",
88
+ );
89
+ });
90
+
91
+ test("returns empty string for blank/whitespace-only path", () => {
92
+ expect(normalizePathForComparison("", cwd)).toBe("");
93
+ expect(normalizePathForComparison(" ", cwd)).toBe("");
94
+ });
95
+ });
96
+
97
+ describe("isPathWithinDirectory", () => {
98
+ test("returns true when path equals directory", () => {
99
+ expect(isPathWithinDirectory("/a/b", "/a/b")).toBe(true);
100
+ });
101
+
102
+ test("returns true when path is a direct child", () => {
103
+ expect(isPathWithinDirectory("/a/b/c", "/a/b")).toBe(true);
104
+ });
105
+
106
+ test("returns true when path is a deep descendant", () => {
107
+ expect(isPathWithinDirectory("/a/b/c/d/e", "/a/b")).toBe(true);
108
+ });
109
+
110
+ test("returns false when path is a sibling directory", () => {
111
+ expect(isPathWithinDirectory("/a/bc", "/a/b")).toBe(false);
112
+ });
113
+
114
+ test("returns false when path is outside the directory", () => {
115
+ expect(isPathWithinDirectory("/other/path", "/a/b")).toBe(false);
116
+ });
117
+
118
+ test("returns false for empty path", () => {
119
+ expect(isPathWithinDirectory("", "/a/b")).toBe(false);
120
+ });
121
+
122
+ test("returns false for empty directory", () => {
123
+ expect(isPathWithinDirectory("/a/b", "")).toBe(false);
124
+ });
125
+
126
+ // ── platform-aware containment (Windows is case-insensitive) ────────────
127
+
128
+ test("win32: folds case for a case-different descendant", () => {
129
+ expect(
130
+ isPathWithinDirectory(
131
+ "c:\\users\\foo\\dir\\sub\\x.md",
132
+ "C:\\Users\\Foo\\dir",
133
+ "win32",
134
+ ),
135
+ ).toBe(true);
136
+ });
137
+
138
+ test("win32: folds case when path equals directory in different case", () => {
139
+ expect(
140
+ isPathWithinDirectory(
141
+ "c:\\users\\foo\\dir\\sub",
142
+ "C:\\USERS\\foo\\DIR",
143
+ "win32",
144
+ ),
145
+ ).toBe(true);
146
+ });
147
+
148
+ test("win32: rejects a sibling directory", () => {
149
+ expect(
150
+ isPathWithinDirectory(
151
+ "C:\\Users\\Foo\\other",
152
+ "C:\\Users\\Foo\\dir",
153
+ "win32",
154
+ ),
155
+ ).toBe(false);
156
+ });
157
+
158
+ test("posix platform stays case-sensitive", () => {
159
+ expect(isPathWithinDirectory("/a/B/c", "/a/b", "linux")).toBe(false);
160
+ });
161
+ });
162
+
163
+ describe("PATH_BEARING_TOOLS", () => {
164
+ test("contains the expected tool names", () => {
165
+ for (const tool of ["read", "write", "edit", "find", "grep", "ls"]) {
166
+ expect(PATH_BEARING_TOOLS.has(tool)).toBe(true);
167
+ }
168
+ });
169
+
170
+ test("does not contain bash or mcp", () => {
171
+ expect(PATH_BEARING_TOOLS.has("bash")).toBe(false);
172
+ expect(PATH_BEARING_TOOLS.has("mcp")).toBe(false);
173
+ });
174
+ });
175
+
176
+ describe("READ_ONLY_PATH_BEARING_TOOLS", () => {
177
+ test("contains read, find, grep, ls", () => {
178
+ for (const tool of ["read", "find", "grep", "ls"]) {
179
+ expect(READ_ONLY_PATH_BEARING_TOOLS.has(tool)).toBe(true);
180
+ }
181
+ });
182
+
183
+ test("does not contain write or edit", () => {
184
+ expect(READ_ONLY_PATH_BEARING_TOOLS.has("write")).toBe(false);
185
+ expect(READ_ONLY_PATH_BEARING_TOOLS.has("edit")).toBe(false);
186
+ });
187
+ });
188
+
189
+ describe("SAFE_SYSTEM_PATHS", () => {
190
+ test("contains /dev/null, /dev/stdin, /dev/stdout, /dev/stderr", () => {
191
+ expect(SAFE_SYSTEM_PATHS.has("/dev/null")).toBe(true);
192
+ expect(SAFE_SYSTEM_PATHS.has("/dev/stdin")).toBe(true);
193
+ expect(SAFE_SYSTEM_PATHS.has("/dev/stdout")).toBe(true);
194
+ expect(SAFE_SYSTEM_PATHS.has("/dev/stderr")).toBe(true);
195
+ });
196
+ });
197
+
198
+ describe("isSafeSystemPath", () => {
199
+ test("returns true for /dev/null", () => {
200
+ expect(isSafeSystemPath("/dev/null")).toBe(true);
201
+ });
202
+
203
+ test("returns true for /dev/stdin", () => {
204
+ expect(isSafeSystemPath("/dev/stdin")).toBe(true);
205
+ });
206
+
207
+ test("returns true for /dev/stdout", () => {
208
+ expect(isSafeSystemPath("/dev/stdout")).toBe(true);
209
+ });
210
+
211
+ test("returns true for /dev/stderr", () => {
212
+ expect(isSafeSystemPath("/dev/stderr")).toBe(true);
213
+ });
214
+
215
+ test("returns false for an arbitrary absolute path", () => {
216
+ expect(isSafeSystemPath("/etc/passwd")).toBe(false);
217
+ });
218
+
219
+ test("returns false for a path prefixed with a safe system path", () => {
220
+ expect(isSafeSystemPath("/dev/null/subdir")).toBe(false);
221
+ });
222
+
223
+ test("returns false for an empty string", () => {
224
+ expect(isSafeSystemPath("")).toBe(false);
225
+ });
226
+
227
+ test("returns false for a relative path", () => {
228
+ expect(isSafeSystemPath("dev/null")).toBe(false);
229
+ });
230
+ });
231
+
232
+ describe("getPathBearingToolPath", () => {
233
+ test("returns path for a path-bearing tool", () => {
234
+ expect(getPathBearingToolPath("read", { path: "/src/foo.ts" })).toBe(
235
+ "/src/foo.ts",
236
+ );
237
+ });
238
+
239
+ test("returns null for a non-path-bearing tool", () => {
240
+ expect(getPathBearingToolPath("bash", { path: "/src/foo.ts" })).toBeNull();
241
+ expect(getPathBearingToolPath("mcp", { path: "/src/foo.ts" })).toBeNull();
242
+ expect(getPathBearingToolPath("task", { path: "/src/foo.ts" })).toBeNull();
243
+ });
244
+
245
+ test("returns null when input has no path", () => {
246
+ expect(getPathBearingToolPath("read", {})).toBeNull();
247
+ expect(getPathBearingToolPath("read", { path: "" })).toBeNull();
248
+ expect(getPathBearingToolPath("read", null)).toBeNull();
249
+ });
250
+ });
251
+
252
+ describe("getToolInputPath", () => {
253
+ function lookupOf(
254
+ toolName: string,
255
+ extractor: (input: Record<string, unknown>) => string | undefined,
256
+ ): ToolAccessExtractorLookup {
257
+ return {
258
+ get: (name) => (name === toolName ? extractor : undefined),
259
+ };
260
+ }
261
+
262
+ test("returns input.path for a built-in path-bearing tool", () => {
263
+ expect(getToolInputPath("read", { path: "/src/foo.ts" })).toBe(
264
+ "/src/foo.ts",
265
+ );
266
+ expect(getToolInputPath("write", { path: "/src/bar.ts" })).toBe(
267
+ "/src/bar.ts",
268
+ );
269
+ });
270
+
271
+ test("returns null for bash", () => {
272
+ expect(getToolInputPath("bash", { path: "/src/foo.ts" })).toBeNull();
273
+ });
274
+
275
+ test("returns the MCP arguments.path for an mcp call", () => {
276
+ expect(getToolInputPath("mcp", { arguments: { path: "/etc/hosts" } })).toBe(
277
+ "/etc/hosts",
278
+ );
279
+ });
280
+
281
+ test("returns null for an mcp call without an arguments.path", () => {
282
+ expect(getToolInputPath("mcp", { arguments: { query: "x" } })).toBeNull();
283
+ expect(getToolInputPath("mcp", {})).toBeNull();
284
+ });
285
+
286
+ test("defaults to input.path for an unregistered extension tool", () => {
287
+ expect(getToolInputPath("my-ext", { path: "/work/file.txt" })).toBe(
288
+ "/work/file.txt",
289
+ );
290
+ });
291
+
292
+ test("returns null for an extension tool without a path", () => {
293
+ expect(getToolInputPath("my-ext", { other: true })).toBeNull();
294
+ expect(getToolInputPath("my-ext", { path: "" })).toBeNull();
295
+ expect(getToolInputPath("my-ext", null)).toBeNull();
296
+ });
297
+
298
+ test("uses a registered extractor's path over the default convention", () => {
299
+ const extractors = lookupOf("ffgrep", (input) =>
300
+ typeof input.target === "string" ? input.target : undefined,
301
+ );
302
+ expect(
303
+ getToolInputPath("ffgrep", { target: "/etc/passwd" }, extractors),
304
+ ).toBe("/etc/passwd");
305
+ });
306
+
307
+ test("returns null when a registered extractor declines", () => {
308
+ const extractors = lookupOf("ffgrep", () => undefined);
309
+ expect(getToolInputPath("ffgrep", { target: "x" }, extractors)).toBeNull();
310
+ });
311
+ });
312
+
313
+ describe("isPathOutsideWorkingDirectory", () => {
314
+ const cwd = "/projects/my-app";
315
+
316
+ beforeEach(() => {
317
+ // Reset then restore the identity default so symlink tests don't bleed.
318
+ realpathSync.mockReset();
319
+ realpathSync.mockImplementation((p: string) => p);
320
+ });
321
+
322
+ test("returns false when path is inside cwd", () => {
323
+ expect(isPathOutsideWorkingDirectory("/projects/my-app/src", cwd)).toBe(
324
+ false,
325
+ );
326
+ });
327
+
328
+ test("returns false when path equals cwd", () => {
329
+ expect(isPathOutsideWorkingDirectory("/projects/my-app", cwd)).toBe(false);
330
+ });
331
+
332
+ test("returns true when path is outside cwd", () => {
333
+ expect(isPathOutsideWorkingDirectory("/etc/passwd", cwd)).toBe(true);
334
+ });
335
+
336
+ test("returns true for home directory when outside cwd", () => {
337
+ expect(isPathOutsideWorkingDirectory("~/secrets", cwd)).toBe(true);
338
+ });
339
+
340
+ test("returns false for relative path resolving inside cwd", () => {
341
+ expect(isPathOutsideWorkingDirectory("src/index.ts", cwd)).toBe(false);
342
+ });
343
+
344
+ test("returns false for empty path (normalizes to empty string)", () => {
345
+ expect(isPathOutsideWorkingDirectory("", cwd)).toBe(false);
346
+ });
347
+
348
+ test("returns false for /dev/null regardless of cwd", () => {
349
+ expect(isPathOutsideWorkingDirectory("/dev/null", cwd)).toBe(false);
350
+ });
351
+
352
+ test("returns false for /dev/stdin regardless of cwd", () => {
353
+ expect(isPathOutsideWorkingDirectory("/dev/stdin", cwd)).toBe(false);
354
+ });
355
+
356
+ test("returns false for /dev/stdout regardless of cwd", () => {
357
+ expect(isPathOutsideWorkingDirectory("/dev/stdout", cwd)).toBe(false);
358
+ });
359
+
360
+ test("returns false for /dev/stderr regardless of cwd", () => {
361
+ expect(isPathOutsideWorkingDirectory("/dev/stderr", cwd)).toBe(false);
362
+ });
363
+
364
+ test("returns true for /dev/null/subdir (not a safe path)", () => {
365
+ expect(isPathOutsideWorkingDirectory("/dev/null/subdir", cwd)).toBe(true);
366
+ });
367
+
368
+ test("returns true for in-cwd symlink that resolves to external path", () => {
369
+ // ./link -> /etc: realpathSync resolves the full token in one call.
370
+ realpathSync.mockImplementation((p: string) => {
371
+ if (p === "/projects/my-app/link/hosts") return "/etc/hosts";
372
+ return p;
373
+ });
374
+ expect(isPathOutsideWorkingDirectory("./link/hosts", cwd)).toBe(true);
375
+ });
376
+
377
+ test("returns false for path inside a symlinked cwd", () => {
378
+ // /tmp -> /private/tmp on macOS; cwd reported as /private/tmp.
379
+ const symlinkCwd = "/private/tmp";
380
+ realpathSync.mockImplementation((p: string) => {
381
+ if (p.startsWith("/tmp/")) return "/private/tmp" + p.slice(4);
382
+ if (p === "/tmp") return "/private/tmp";
383
+ return p;
384
+ });
385
+ expect(
386
+ isPathOutsideWorkingDirectory("/tmp/workspace/file.ts", symlinkCwd),
387
+ ).toBe(false);
388
+ });
389
+ });
390
+
391
+ describe("canonicalNormalizePathForComparison", () => {
392
+ const cwd = "/projects/my-app";
393
+
394
+ beforeEach(() => {
395
+ realpathSync.mockReset();
396
+ realpathSync.mockImplementation((p: string) => p);
397
+ });
398
+
399
+ test("returns canonical form of an existing path", () => {
400
+ realpathSync.mockImplementation((p: string) => {
401
+ if (p === "/projects/link") return "/real/projects/app";
402
+ return p;
403
+ });
404
+ expect(canonicalNormalizePathForComparison("/projects/link", cwd)).toBe(
405
+ "/real/projects/app",
406
+ );
407
+ });
408
+
409
+ test("returns empty string for empty input", () => {
410
+ expect(canonicalNormalizePathForComparison("", cwd)).toBe("");
411
+ });
412
+
413
+ test("returns lexical form when no symlinks (identity realpathSync)", () => {
414
+ expect(
415
+ canonicalNormalizePathForComparison("/projects/my-app/src/index.ts", cwd),
416
+ ).toBe("/projects/my-app/src/index.ts");
417
+ });
418
+ });
419
+
420
+ describe("isPiInfrastructureRead", () => {
421
+ const cwd = "/projects/my-app";
422
+ const infraDirs = ["/mock/home/.pi/agent"];
423
+
424
+ test("returns true for read-only tool reading from infra dir", () => {
425
+ expect(
426
+ isPiInfrastructureRead(
427
+ "read",
428
+ "/mock/home/.pi/agent/config.json",
429
+ infraDirs,
430
+ cwd,
431
+ ),
432
+ ).toBe(true);
433
+ });
434
+
435
+ test("returns false for write tool even in infra dir", () => {
436
+ expect(
437
+ isPiInfrastructureRead(
438
+ "write",
439
+ "/mock/home/.pi/agent/config.json",
440
+ infraDirs,
441
+ cwd,
442
+ ),
443
+ ).toBe(false);
444
+ });
445
+
446
+ test("returns true for read-only tool reading from project .pi/npm", () => {
447
+ expect(
448
+ isPiInfrastructureRead(
449
+ "read",
450
+ "/projects/my-app/.pi/npm/package.json",
451
+ [],
452
+ cwd,
453
+ ),
454
+ ).toBe(true);
455
+ });
456
+
457
+ test("returns true for read-only tool reading from project .pi/git", () => {
458
+ expect(
459
+ isPiInfrastructureRead(
460
+ "grep",
461
+ "/projects/my-app/.pi/git/some-file",
462
+ [],
463
+ cwd,
464
+ ),
465
+ ).toBe(true);
466
+ });
467
+
468
+ test("returns false for path outside all infra dirs and project dirs", () => {
469
+ expect(isPiInfrastructureRead("read", "/etc/passwd", infraDirs, cwd)).toBe(
470
+ false,
471
+ );
472
+ });
473
+
474
+ // ── glob patterns ─────────────────────────────────────────────────
475
+
476
+ test("glob entry matches a versioned path", () => {
477
+ expect(
478
+ isPiInfrastructureRead(
479
+ "read",
480
+ "/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
481
+ ["/opt/homebrew/**/@earendil-works/pi-coding-agent/**"],
482
+ cwd,
483
+ ),
484
+ ).toBe(true);
485
+ });
486
+
487
+ test("glob entry does not match an unrelated path", () => {
488
+ expect(
489
+ isPiInfrastructureRead(
490
+ "read",
491
+ "/etc/passwd",
492
+ ["/opt/homebrew/**/@earendil-works/pi-coding-agent/**"],
493
+ cwd,
494
+ ),
495
+ ).toBe(false);
496
+ });
497
+
498
+ test("plain entry with ~ expands to home dir for matching", () => {
499
+ // node:os is mocked: homedir() returns "/mock/home"
500
+ expect(
501
+ isPiInfrastructureRead(
502
+ "read",
503
+ "/mock/home/.pi/agent/config.json",
504
+ ["~/.pi/agent"],
505
+ cwd,
506
+ ),
507
+ ).toBe(true);
508
+ });
509
+
510
+ // ── Windows: case-insensitive infra-read matching ─────────────────────
511
+
512
+ test("win32: plain infra dir matches a case-different path", () => {
513
+ expect(
514
+ isPiInfrastructureRead(
515
+ "read",
516
+ "c:\\users\\foo\\.pi\\agent\\config.json",
517
+ ["C:\\Users\\Foo\\.pi\\agent"],
518
+ "C:\\proj",
519
+ "win32",
520
+ ),
521
+ ).toBe(true);
522
+ });
523
+
524
+ test("win32: glob infra dir matches case-insensitively", () => {
525
+ expect(
526
+ isPiInfrastructureRead(
527
+ "read",
528
+ "c:\\users\\foo\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\skill.md",
529
+ ["C:\\Users\\Foo\\**\\pi-coding-agent\\**"],
530
+ "C:\\proj",
531
+ "win32",
532
+ ),
533
+ ).toBe(true);
534
+ });
535
+
536
+ test("win32: rejects a path outside every infra dir", () => {
537
+ expect(
538
+ isPiInfrastructureRead(
539
+ "read",
540
+ "c:\\windows\\system32\\drivers\\etc\\hosts",
541
+ ["C:\\Users\\Foo\\.pi\\agent"],
542
+ "C:\\proj",
543
+ "win32",
544
+ ),
545
+ ).toBe(false);
546
+ });
547
+ });
548
+
549
+ describe("normalizePathPolicyLiteral", () => {
550
+ test("returns a relative token unchanged", () => {
551
+ expect(normalizePathPolicyLiteral("src/foo.ts")).toBe("src/foo.ts");
552
+ });
553
+
554
+ test("trims and strips simple wrapping quotes", () => {
555
+ expect(normalizePathPolicyLiteral(" 'src/foo.ts' ")).toBe("src/foo.ts");
556
+ expect(normalizePathPolicyLiteral('"a/b"')).toBe("a/b");
557
+ });
558
+
559
+ test("strips a leading @ prefix", () => {
560
+ expect(normalizePathPolicyLiteral("@src/foo.ts")).toBe("src/foo.ts");
561
+ });
562
+
563
+ test("expands ~ to the home directory", () => {
564
+ expect(normalizePathPolicyLiteral("~/docs/readme.md")).toBe(
565
+ join("/mock/home", "docs/readme.md"),
566
+ );
567
+ });
568
+
569
+ test("does not resolve a relative value against any cwd", () => {
570
+ expect(normalizePathPolicyLiteral("foo.ts")).toBe("foo.ts");
571
+ });
572
+
573
+ test("returns empty string for blank input", () => {
574
+ expect(normalizePathPolicyLiteral(" ")).toBe("");
575
+ });
576
+
577
+ test("preserves the surface catch-all", () => {
578
+ expect(normalizePathPolicyLiteral("*")).toBe("*");
579
+ });
580
+ });
581
+
582
+ describe("getPathPolicyValues", () => {
583
+ const cwd = "/projects/my-app";
584
+
585
+ test("returns only the literal when no base is available", () => {
586
+ expect(getPathPolicyValues("src/foo.ts")).toEqual(["src/foo.ts"]);
587
+ expect(getPathPolicyValues("src/foo.ts", {})).toEqual(["src/foo.ts"]);
588
+ });
589
+
590
+ test("adds absolute and project-relative aliases for a relative token", () => {
591
+ expect(getPathPolicyValues("src/foo.ts", { cwd })).toEqual([
592
+ "/projects/my-app/src/foo.ts",
593
+ "src/foo.ts",
594
+ ]);
595
+ });
596
+
597
+ test("omits the relative alias for a token outside cwd", () => {
598
+ expect(getPathPolicyValues("/etc/hosts", { cwd })).toEqual(["/etc/hosts"]);
599
+ });
600
+
601
+ test("resolves against resolveBase while aliasing relative to cwd", () => {
602
+ expect(
603
+ getPathPolicyValues("foo.txt", {
604
+ cwd,
605
+ resolveBase: "/projects/my-app/nested",
606
+ }),
607
+ ).toEqual(["/projects/my-app/nested/foo.txt", "nested/foo.txt", "foo.txt"]);
608
+ });
609
+
610
+ test("preserves the surface catch-all", () => {
611
+ expect(getPathPolicyValues("*", { cwd })).toEqual(["*"]);
612
+ });
613
+
614
+ test("returns empty for blank input", () => {
615
+ expect(getPathPolicyValues(" ", { cwd })).toEqual([]);
616
+ });
617
+ });
618
+
619
+ describe("getExternalDirectoryPolicyValues", () => {
620
+ const cwd = "/projects/my-app";
621
+
622
+ beforeEach(() => {
623
+ realpathSync.mockReset();
624
+ realpathSync.mockImplementation((p: string) => p);
625
+ });
626
+
627
+ test("adds the symlink-resolved alias alongside the typed path", () => {
628
+ // /tmp -> /private/tmp (the macOS symlink from the bug report).
629
+ realpathSync.mockImplementation((p: string) =>
630
+ p.startsWith("/tmp") ? `/private${p}` : p,
631
+ );
632
+ expect(getExternalDirectoryPolicyValues("/tmp/x", cwd)).toEqual([
633
+ "/tmp/x",
634
+ "/private/tmp/x",
635
+ ]);
636
+ });
637
+
638
+ test("dedups when the canonical form equals the lexical form", () => {
639
+ expect(getExternalDirectoryPolicyValues("/etc/hosts", cwd)).toEqual([
640
+ "/etc/hosts",
641
+ ]);
642
+ });
643
+
644
+ test("keeps the relative aliases for an in-cwd token without duplicating", () => {
645
+ expect(getExternalDirectoryPolicyValues("src/foo.ts", cwd)).toEqual([
646
+ "/projects/my-app/src/foo.ts",
647
+ "src/foo.ts",
648
+ ]);
649
+ });
650
+ });