@gotgenes/pi-permission-system 18.1.1 → 18.2.0

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 (133) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/docs/configuration.md +11 -0
  3. package/package.json +1 -2
  4. package/src/access-intent/access-path.ts +29 -4
  5. package/src/access-intent/bash/bash-path-resolver.ts +39 -18
  6. package/src/access-intent/bash/msys-bash-tokens.ts +64 -0
  7. package/src/extension-config.ts +7 -0
  8. package/src/forwarded-permissions/permission-forwarder.ts +6 -3
  9. package/src/handlers/gates/helpers.ts +1 -1
  10. package/src/handlers/gates/runner.ts +22 -0
  11. package/src/index.ts +10 -6
  12. package/src/path-normalizer.ts +105 -2
  13. package/src/permission-manager.ts +21 -1
  14. package/src/permission-prompter.ts +10 -16
  15. package/src/prompting-gateway.ts +13 -17
  16. package/src/rule.ts +21 -1
  17. package/src/status.ts +1 -1
  18. package/src/yolo-mode.ts +0 -30
  19. package/test/access-intent/access-path.test.ts +0 -277
  20. package/test/access-intent/bash/node-text.test.ts +0 -148
  21. package/test/access-intent/bash/parser.test.ts +0 -19
  22. package/test/access-intent/bash/program.test.ts +0 -673
  23. package/test/access-intent/bash/token-classification.test.ts +0 -363
  24. package/test/access-intent/bash/token-collection.test.ts +0 -300
  25. package/test/active-agent.test.ts +0 -155
  26. package/test/async-cache.test.ts +0 -48
  27. package/test/bash-arity.test.ts +0 -144
  28. package/test/bash-external-directory.test.ts +0 -1022
  29. package/test/builtin-tool-input-formatters.test.ts +0 -109
  30. package/test/canonicalize-path.test.ts +0 -119
  31. package/test/composition-root.test.ts +0 -698
  32. package/test/config-loader.test.ts +0 -740
  33. package/test/config-modal.test.ts +0 -320
  34. package/test/config-paths.test.ts +0 -83
  35. package/test/config-pipeline.test.ts +0 -90
  36. package/test/config-reporter.test.ts +0 -147
  37. package/test/config-store.test.ts +0 -466
  38. package/test/decision-audit.test.ts +0 -72
  39. package/test/decision-reporter.test.ts +0 -112
  40. package/test/denial-messages.test.ts +0 -714
  41. package/test/detect-permissive-bash-fallback.test.ts +0 -56
  42. package/test/expand-home.test.ts +0 -93
  43. package/test/extension-config.test.ts +0 -129
  44. package/test/extension-paths.test.ts +0 -108
  45. package/test/forwarded-permissions/io.test.ts +0 -251
  46. package/test/forwarding-manager.test.ts +0 -200
  47. package/test/handlers/before-agent-start.test.ts +0 -314
  48. package/test/handlers/external-directory-integration.test.ts +0 -515
  49. package/test/handlers/external-directory-session-dedup.test.ts +0 -175
  50. package/test/handlers/external-directory-symlink-acceptance.test.ts +0 -167
  51. package/test/handlers/gates/bash-command-metamorphic.test.ts +0 -88
  52. package/test/handlers/gates/bash-command.test.ts +0 -257
  53. package/test/handlers/gates/bash-external-directory.test.ts +0 -268
  54. package/test/handlers/gates/bash-path.test.ts +0 -346
  55. package/test/handlers/gates/candidate-check.test.ts +0 -52
  56. package/test/handlers/gates/external-directory-messages.test.ts +0 -85
  57. package/test/handlers/gates/external-directory-policy.test.ts +0 -134
  58. package/test/handlers/gates/external-directory.test.ts +0 -267
  59. package/test/handlers/gates/helpers.test.ts +0 -165
  60. package/test/handlers/gates/path.test.ts +0 -369
  61. package/test/handlers/gates/runner.test.ts +0 -408
  62. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +0 -176
  63. package/test/handlers/gates/skill-input.test.ts +0 -128
  64. package/test/handlers/gates/skill-read.test.ts +0 -161
  65. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +0 -356
  66. package/test/handlers/gates/tool.test.ts +0 -244
  67. package/test/handlers/input-events.test.ts +0 -168
  68. package/test/handlers/input.test.ts +0 -199
  69. package/test/handlers/lifecycle.test.ts +0 -221
  70. package/test/handlers/tool-call-boundary.test.ts +0 -145
  71. package/test/handlers/tool-call-events.test.ts +0 -277
  72. package/test/handlers/tool-call.test.ts +0 -395
  73. package/test/handlers/validate-requested-tool.test.ts +0 -92
  74. package/test/helpers/external-directory-fixtures.ts +0 -269
  75. package/test/helpers/gate-fixtures.ts +0 -316
  76. package/test/helpers/handler-fixtures.ts +0 -335
  77. package/test/helpers/make-fake-pi.ts +0 -100
  78. package/test/helpers/manager-harness.ts +0 -112
  79. package/test/helpers/session-fixtures.ts +0 -199
  80. package/test/input-normalizer.test.ts +0 -325
  81. package/test/logging.test.ts +0 -51
  82. package/test/mcp-targets.test.ts +0 -233
  83. package/test/node-modules-discovery.test.ts +0 -97
  84. package/test/normalize.test.ts +0 -247
  85. package/test/path-containment.test.ts +0 -161
  86. package/test/path-normalization.test.ts +0 -233
  87. package/test/path-normalizer.test.ts +0 -196
  88. package/test/path-surfaces.test.ts +0 -55
  89. package/test/pattern-suggest.test.ts +0 -248
  90. package/test/permission-dialog.test.ts +0 -205
  91. package/test/permission-event-rpc.test.ts +0 -560
  92. package/test/permission-events.test.ts +0 -400
  93. package/test/permission-forwarder.test.ts +0 -370
  94. package/test/permission-forwarding.test.ts +0 -315
  95. package/test/permission-gate.test.ts +0 -269
  96. package/test/permission-manager-unified.test.ts +0 -3714
  97. package/test/permission-merge.test.ts +0 -61
  98. package/test/permission-prompter.test.ts +0 -518
  99. package/test/permission-prompts.test.ts +0 -363
  100. package/test/permission-resolver.test.ts +0 -277
  101. package/test/permission-session.test.ts +0 -404
  102. package/test/permission-ui-prompt.test.ts +0 -146
  103. package/test/permissions-service.test.ts +0 -192
  104. package/test/pi-infrastructure-read.test.ts +0 -432
  105. package/test/policy-loader.test.ts +0 -561
  106. package/test/prompting-gateway.test.ts +0 -231
  107. package/test/rule.test.ts +0 -650
  108. package/test/safe-system-paths.test.ts +0 -46
  109. package/test/scope-merge.test.ts +0 -116
  110. package/test/service-lifecycle.test.ts +0 -163
  111. package/test/service.test.ts +0 -261
  112. package/test/session-approval.test.ts +0 -75
  113. package/test/session-logger.test.ts +0 -200
  114. package/test/session-rules.test.ts +0 -321
  115. package/test/session-start.test.ts +0 -112
  116. package/test/skill-prompt-sanitizer.test.ts +0 -418
  117. package/test/status.test.ts +0 -10
  118. package/test/subagent-context.test.ts +0 -372
  119. package/test/subagent-lifecycle-events.test.ts +0 -132
  120. package/test/subagent-registry.test.ts +0 -145
  121. package/test/synthesize.test.ts +0 -302
  122. package/test/system-prompt-sanitizer.test.ts +0 -382
  123. package/test/tool-access-extractor-registry.test.ts +0 -77
  124. package/test/tool-input-formatter-registry.test.ts +0 -75
  125. package/test/tool-input-path.test.ts +0 -84
  126. package/test/tool-input-preview.test.ts +0 -129
  127. package/test/tool-input-prompt-formatters.test.ts +0 -115
  128. package/test/tool-preview-formatter.test.ts +0 -458
  129. package/test/tool-registry.test.ts +0 -197
  130. package/test/value-guards.test.ts +0 -193
  131. package/test/wildcard-matcher.test.ts +0 -424
  132. package/test/yaml-frontmatter.test.ts +0 -91
  133. package/test/yolo-mode.test.ts +0 -188
@@ -1,1022 +0,0 @@
1
- import { afterEach, describe, expect, test, vi } from "vitest";
2
-
3
- // Mock node:os so tilde-expansion is deterministic across platforms.
4
- vi.mock("node:os", () => {
5
- const homedir = vi.fn(() => "/mock/home");
6
- return {
7
- homedir,
8
- default: { homedir },
9
- };
10
- });
11
-
12
- // Mock node:fs with an identity realpathSync so canonicalizePath
13
- // (used by BashProgram.externalPaths) leaves test paths unchanged and
14
- // existing expected-value literals remain accurate across platforms.
15
- vi.mock("node:fs", () => ({
16
- realpathSync: (p: string) => p,
17
- default: { realpathSync: (p: string) => p },
18
- }));
19
-
20
- import { formatDenyReason } from "#src/denial-messages";
21
- import { extractExternalPathsFromBashCommand as extractWithNormalizer } from "#src/handlers/gates/bash-path-extractor";
22
- import { formatBashExternalDirectoryAskPrompt } from "#src/handlers/gates/external-directory-messages";
23
- import { PathNormalizer } from "#src/path-normalizer";
24
-
25
- afterEach(() => {
26
- vi.restoreAllMocks();
27
- });
28
-
29
- // The production facade now takes a PathNormalizer (platform + cwd baked in);
30
- // this wrapper preserves the (command, cwd) call shape the suite uses
31
- // throughout so the projection-correctness assertions stay unchanged.
32
- function extractExternalPathsFromBashCommand(
33
- command: string,
34
- cwd: string,
35
- ): Promise<string[]> {
36
- return extractWithNormalizer(
37
- command,
38
- new PathNormalizer(process.platform, cwd),
39
- );
40
- }
41
-
42
- describe("extractExternalPathsFromBashCommand", () => {
43
- const cwd = "/projects/my-app";
44
-
45
- describe("absolute paths", () => {
46
- test("detects absolute path outside CWD", async () => {
47
- const result = await extractExternalPathsFromBashCommand(
48
- "cat /etc/hosts",
49
- cwd,
50
- );
51
- expect(result).toContain("/etc/hosts");
52
- });
53
-
54
- test("detects multiple absolute paths outside CWD", async () => {
55
- const result = await extractExternalPathsFromBashCommand(
56
- "diff /etc/hosts /var/log/syslog",
57
- cwd,
58
- );
59
- expect(result).toContain("/etc/hosts");
60
- expect(result).toContain("/var/log/syslog");
61
- });
62
-
63
- test("does not flag absolute path within CWD", async () => {
64
- const result = await extractExternalPathsFromBashCommand(
65
- "cat /projects/my-app/src/index.ts",
66
- cwd,
67
- );
68
- expect(result).toHaveLength(0);
69
- });
70
- });
71
-
72
- describe("home-relative paths", () => {
73
- test("detects ~/path outside CWD", async () => {
74
- const result = await extractExternalPathsFromBashCommand(
75
- "cat ~/documents/secret.txt",
76
- cwd,
77
- );
78
- expect(result).toContain("/mock/home/documents/secret.txt");
79
- });
80
-
81
- test("does not flag ~/path that resolves within CWD", async () => {
82
- // CWD is under /mock/home for this test
83
- const result = await extractExternalPathsFromBashCommand(
84
- "cat ~/myproject/file.ts",
85
- "/mock/home/myproject",
86
- );
87
- expect(result).toHaveLength(0);
88
- });
89
- });
90
-
91
- describe("dot-dot relative paths", () => {
92
- test("detects ../ path that resolves outside CWD", async () => {
93
- const result = await extractExternalPathsFromBashCommand(
94
- "cat ../../other-project/secrets.env",
95
- cwd,
96
- );
97
- expect(result).toContain("/other-project/secrets.env");
98
- });
99
-
100
- test("does not flag ../ path that stays within CWD", async () => {
101
- const result = await extractExternalPathsFromBashCommand(
102
- "cat src/../lib/utils.ts",
103
- cwd,
104
- );
105
- expect(result).toHaveLength(0);
106
- });
107
- });
108
-
109
- describe("commands within CWD only", () => {
110
- test("returns empty for relative paths within CWD", async () => {
111
- const result = await extractExternalPathsFromBashCommand(
112
- "cat src/index.ts",
113
- cwd,
114
- );
115
- expect(result).toHaveLength(0);
116
- });
117
-
118
- test("returns empty for bare command with no path arguments", async () => {
119
- const result = await extractExternalPathsFromBashCommand(
120
- "git status",
121
- cwd,
122
- );
123
- expect(result).toHaveLength(0);
124
- });
125
- });
126
-
127
- describe("flags are skipped", () => {
128
- test("does not treat flags as paths", async () => {
129
- const result = await extractExternalPathsFromBashCommand(
130
- "ls -la --color=auto",
131
- cwd,
132
- );
133
- expect(result).toHaveLength(0);
134
- });
135
-
136
- test("detects path after flags", async () => {
137
- const result = await extractExternalPathsFromBashCommand(
138
- "ls -la /etc/passwd",
139
- cwd,
140
- );
141
- expect(result).toContain("/etc/passwd");
142
- });
143
- });
144
-
145
- describe("env assignments are skipped", () => {
146
- test("does not treat FOO=/bar as a path", async () => {
147
- const result = await extractExternalPathsFromBashCommand(
148
- "FOO=/usr/local/bin command",
149
- cwd,
150
- );
151
- expect(result).toHaveLength(0);
152
- });
153
- });
154
-
155
- describe("shell metacharacters split correctly", () => {
156
- test("detects path after pipe", async () => {
157
- const result = await extractExternalPathsFromBashCommand(
158
- "echo hello | tee /tmp/output.txt",
159
- cwd,
160
- );
161
- expect(result).toContain("/tmp/output.txt");
162
- });
163
-
164
- test("detects path after semicolon", async () => {
165
- const result = await extractExternalPathsFromBashCommand(
166
- "echo done; cat /etc/hosts",
167
- cwd,
168
- );
169
- expect(result).toContain("/etc/hosts");
170
- });
171
-
172
- test("detects path after &&", async () => {
173
- const result = await extractExternalPathsFromBashCommand(
174
- "true && cat /etc/hosts",
175
- cwd,
176
- );
177
- expect(result).toContain("/etc/hosts");
178
- });
179
-
180
- test("detects path in redirect target", async () => {
181
- const result = await extractExternalPathsFromBashCommand(
182
- "echo hello > /tmp/out.txt",
183
- cwd,
184
- );
185
- expect(result).toContain("/tmp/out.txt");
186
- });
187
- });
188
-
189
- describe("URLs are skipped", () => {
190
- test("does not treat http:// URL as a path", async () => {
191
- const result = await extractExternalPathsFromBashCommand(
192
- "curl http://example.com/path",
193
- cwd,
194
- );
195
- expect(result).toHaveLength(0);
196
- });
197
-
198
- test("does not treat https:// URL as a path", async () => {
199
- const result = await extractExternalPathsFromBashCommand(
200
- "curl https://example.com/etc/hosts",
201
- cwd,
202
- );
203
- expect(result).toHaveLength(0);
204
- });
205
- });
206
-
207
- describe("@scope/package patterns are skipped", () => {
208
- test("does not treat @scope/package as a path", async () => {
209
- const result = await extractExternalPathsFromBashCommand(
210
- "npm install @types/node",
211
- cwd,
212
- );
213
- expect(result).toHaveLength(0);
214
- });
215
- });
216
-
217
- describe("quoted strings are ignored", () => {
218
- test("does not flag path inside double-quoted string", async () => {
219
- const result = await extractExternalPathsFromBashCommand(
220
- 'git commit -m "fix: update /etc/hosts handler"',
221
- cwd,
222
- );
223
- expect(result).toHaveLength(0);
224
- });
225
-
226
- test("does not flag path inside single-quoted string", async () => {
227
- const result = await extractExternalPathsFromBashCommand(
228
- "echo 'see /usr/local/docs for info'",
229
- cwd,
230
- );
231
- expect(result).toHaveLength(0);
232
- });
233
-
234
- test("still flags unquoted path alongside quoted content", async () => {
235
- const result = await extractExternalPathsFromBashCommand(
236
- 'cat /etc/hosts && echo "done"',
237
- cwd,
238
- );
239
- expect(result).toContain("/etc/hosts");
240
- });
241
-
242
- test("does not flag path when adjacent quoted segments form one word", async () => {
243
- // tree-sitter parses adjacent quoted/unquoted segments as a concatenation node
244
- // whose resolved text is 'path is /etc/hosts' (one token, not a path candidate).
245
- const result = await extractExternalPathsFromBashCommand(
246
- 'echo "path is "/etc/hosts""',
247
- cwd,
248
- );
249
- expect(result).toHaveLength(0);
250
- });
251
- });
252
-
253
- describe("safe system paths are filtered", () => {
254
- test("does not flag /dev/null in stderr redirect", async () => {
255
- const result = await extractExternalPathsFromBashCommand(
256
- "command 2>/dev/null",
257
- cwd,
258
- );
259
- expect(result).toHaveLength(0);
260
- });
261
-
262
- test("does not flag /dev/null as a redirect target", async () => {
263
- const result = await extractExternalPathsFromBashCommand(
264
- "echo hello > /dev/null",
265
- cwd,
266
- );
267
- expect(result).toHaveLength(0);
268
- });
269
-
270
- test("does not flag /dev/stdin", async () => {
271
- const result = await extractExternalPathsFromBashCommand(
272
- "cat /dev/stdin",
273
- cwd,
274
- );
275
- expect(result).toHaveLength(0);
276
- });
277
-
278
- test("does not flag /dev/stdout", async () => {
279
- const result = await extractExternalPathsFromBashCommand(
280
- "cat /dev/stdout",
281
- cwd,
282
- );
283
- expect(result).toHaveLength(0);
284
- });
285
-
286
- test("does not flag /dev/stderr", async () => {
287
- const result = await extractExternalPathsFromBashCommand(
288
- "cat /dev/stderr",
289
- cwd,
290
- );
291
- expect(result).toHaveLength(0);
292
- });
293
-
294
- test("still flags a real external path alongside /dev/null", async () => {
295
- const result = await extractExternalPathsFromBashCommand(
296
- "cat /etc/hosts 2>/dev/null",
297
- cwd,
298
- );
299
- expect(result).toContain("/etc/hosts");
300
- expect(result).not.toContain("/dev/null");
301
- });
302
-
303
- test("does not flag /dev/null/subdir (not a safe path)", async () => {
304
- const result = await extractExternalPathsFromBashCommand(
305
- "cat /dev/null/subdir",
306
- cwd,
307
- );
308
- expect(result).toContain("/dev/null/subdir");
309
- });
310
- });
311
-
312
- describe("bare-slash tokens are skipped", () => {
313
- test("does not flag // token", async () => {
314
- const result = await extractExternalPathsFromBashCommand("echo //", cwd);
315
- expect(result).toHaveLength(0);
316
- });
317
-
318
- test("does not flag / token", async () => {
319
- const result = await extractExternalPathsFromBashCommand("echo /", cwd);
320
- expect(result).toHaveLength(0);
321
- });
322
-
323
- test("does not flag /// token", async () => {
324
- const result = await extractExternalPathsFromBashCommand("echo ///", cwd);
325
- expect(result).toHaveLength(0);
326
- });
327
-
328
- test("does not flag // in echo with other args", async () => {
329
- const result = await extractExternalPathsFromBashCommand(
330
- "echo // hello",
331
- cwd,
332
- );
333
- expect(result).toHaveLength(0);
334
- });
335
-
336
- test("bare-slash guard is still needed: tree-sitter emits / as a word node", async () => {
337
- // tree-sitter parses 'echo /' with '/' as a word argument node.
338
- // classifyTokenAsPathCandidate must still reject it.
339
- // This test documents that the /^\/+$/ guard remains a necessary
340
- // defense-in-depth layer even with tree-sitter as the parser.
341
- const result = await extractExternalPathsFromBashCommand("echo /", cwd);
342
- expect(result).toHaveLength(0);
343
- });
344
-
345
- test("bare double-slash guard with tree-sitter", async () => {
346
- // tree-sitter also emits '//' as a word node — guard must reject it.
347
- const result = await extractExternalPathsFromBashCommand("echo //", cwd);
348
- expect(result).toHaveLength(0);
349
- });
350
-
351
- test("still flags real external path alongside //", async () => {
352
- const result = await extractExternalPathsFromBashCommand(
353
- "cat /etc/hosts; echo //",
354
- cwd,
355
- );
356
- expect(result).toContain("/etc/hosts");
357
- expect(result).toHaveLength(1);
358
- });
359
- });
360
-
361
- describe("node -e and multi-line commands", () => {
362
- test("does not flag path inside single-quoted string in node -e argument", async () => {
363
- const result = await extractExternalPathsFromBashCommand(
364
- "node -e \"const p = '/etc/hosts'; console.log(p);\"",
365
- cwd,
366
- );
367
- expect(result).toHaveLength(0);
368
- });
369
-
370
- test("does not flag path inside multi-line node -e argument", async () => {
371
- // Actual newlines inside the double-quoted -e argument.
372
- const cmd =
373
- "node -e \"\nimport('x').then(() => {\n console.log('/etc/hosts');\n});\n\"";
374
- const result = await extractExternalPathsFromBashCommand(cmd, cwd);
375
- expect(result).toHaveLength(0);
376
- });
377
-
378
- test("does not flag path that appears after escaped quote in multi-line node -e argument", async () => {
379
- // This is the shape of the command that triggered a prompt during dog-fooding.
380
- // The outer \"...\" arg contains both actual newlines and \\" escape sequences,
381
- // with /etc/hosts appearing after a \\" boundary.
382
- const cmd = [
383
- 'node -e "',
384
- "import('shell-quote').then(({ parse }) => {",
385
- " const cmd = \\\"cat << 'EOF'\\n/etc/hosts\\nsome content\\nEOF\\\";",
386
- " console.log(JSON.stringify(parse(cmd)));",
387
- "});",
388
- '"',
389
- ].join("\n");
390
- const result = await extractExternalPathsFromBashCommand(cmd, cwd);
391
- expect(result).toHaveLength(0);
392
- });
393
- });
394
-
395
- describe("tokenizer edge cases", () => {
396
- test("does not flag path inside string when escaped quote is present", async () => {
397
- // tree-sitter correctly parses the escaped quote and keeps the path inside the string.
398
- const result = await extractExternalPathsFromBashCommand(
399
- 'git commit -m "fix: update \\"the /etc/hosts\\" handler"',
400
- cwd,
401
- );
402
- expect(result).toHaveLength(0);
403
- });
404
-
405
- test("does not flag path appearing only in a shell comment", async () => {
406
- const result = await extractExternalPathsFromBashCommand(
407
- "echo hello # /etc/shadow",
408
- cwd,
409
- );
410
- expect(result).toHaveLength(0);
411
- });
412
-
413
- test("flags real path before comment but not path inside comment", async () => {
414
- const result = await extractExternalPathsFromBashCommand(
415
- "cat /etc/hosts # see also /etc/shadow",
416
- cwd,
417
- );
418
- expect(result).toContain("/etc/hosts");
419
- expect(result).not.toContain("/etc/shadow");
420
- expect(result).toHaveLength(1);
421
- });
422
- });
423
-
424
- describe("heredoc handling", () => {
425
- test("does not flag path inside single-quoted heredoc delimiter", async () => {
426
- const cmd = "cat << 'EOF'\n/etc/hosts\nEOF";
427
- const result = await extractExternalPathsFromBashCommand(cmd, cwd);
428
- expect(result).toHaveLength(0);
429
- });
430
-
431
- test("does not flag path inside double-quoted heredoc delimiter", async () => {
432
- const cmd = 'cat << "EOF"\n/etc/hosts\nEOF';
433
- const result = await extractExternalPathsFromBashCommand(cmd, cwd);
434
- expect(result).toHaveLength(0);
435
- });
436
-
437
- test("does not flag path inside unquoted heredoc delimiter", async () => {
438
- const cmd = "cat << EOF\n/etc/hosts\nEOF";
439
- const result = await extractExternalPathsFromBashCommand(cmd, cwd);
440
- expect(result).toHaveLength(0);
441
- });
442
-
443
- test("flags real path alongside heredoc but not heredoc content", async () => {
444
- const cmd = "cat /etc/hosts << 'EOF'\nsome content\nEOF";
445
- const result = await extractExternalPathsFromBashCommand(cmd, cwd);
446
- expect(result).toContain("/etc/hosts");
447
- expect(result).toHaveLength(1);
448
- });
449
-
450
- test("does not flag path inside indented heredoc (<<-)", async () => {
451
- const cmd = "cat <<- 'EOF'\n\t/etc/hosts\nEOF";
452
- const result = await extractExternalPathsFromBashCommand(cmd, cwd);
453
- expect(result).toHaveLength(0);
454
- });
455
- });
456
-
457
- describe("defense-in-depth guards with tree-sitter", () => {
458
- test("env assignment is a variable_assignment node, not a command argument", async () => {
459
- // tree-sitter parses FOO=/usr/local/bin as a variable_assignment node.
460
- // The walker skips variable_assignment, so the env-assignment guard in
461
- // classifyTokenAsPathCandidate is defense-in-depth.
462
- const result = await extractExternalPathsFromBashCommand(
463
- "FOO=/usr/local/bin command",
464
- cwd,
465
- );
466
- expect(result).toHaveLength(0);
467
- });
468
-
469
- test("URL is a word argument, classifyTokenAsPathCandidate rejects it", async () => {
470
- // tree-sitter emits the URL as a plain word argument.
471
- // classifyTokenAsPathCandidate's URL pattern must still reject it.
472
- const result = await extractExternalPathsFromBashCommand(
473
- "curl https://example.com/etc/hosts",
474
- cwd,
475
- );
476
- expect(result).toHaveLength(0);
477
- });
478
-
479
- test("flag arguments are word nodes, classifyTokenAsPathCandidate rejects them", async () => {
480
- // tree-sitter emits '-la' as a word argument.
481
- // classifyTokenAsPathCandidate's flag check must still reject it.
482
- const result = await extractExternalPathsFromBashCommand(
483
- "ls -la --color=auto",
484
- cwd,
485
- );
486
- expect(result).toHaveLength(0);
487
- });
488
- });
489
-
490
- describe("command substitution", () => {
491
- test("detects path inside command substitution", async () => {
492
- const result = await extractExternalPathsFromBashCommand(
493
- "echo $(cat /etc/hosts)",
494
- cwd,
495
- );
496
- expect(result).toContain("/etc/hosts");
497
- });
498
-
499
- test("detects path inside nested command substitution", async () => {
500
- const result = await extractExternalPathsFromBashCommand(
501
- "echo $(echo $(cat /etc/hosts))",
502
- cwd,
503
- );
504
- expect(result).toContain("/etc/hosts");
505
- });
506
-
507
- test("does not flag command substitution inside single-quoted heredoc", async () => {
508
- // Single-quoted heredoc delimiter prevents expansion — content is literal.
509
- const cmd = "cat << 'EOF'\n$(cat /etc/hosts)\nEOF";
510
- const result = await extractExternalPathsFromBashCommand(cmd, cwd);
511
- expect(result).toHaveLength(0);
512
- });
513
-
514
- test("detects path in subshell", async () => {
515
- const result = await extractExternalPathsFromBashCommand(
516
- "(cat /etc/hosts)",
517
- cwd,
518
- );
519
- expect(result).toContain("/etc/hosts");
520
- });
521
- });
522
-
523
- describe("redirect targets", () => {
524
- test("detects path in output redirect", async () => {
525
- const result = await extractExternalPathsFromBashCommand(
526
- "echo hello > /tmp/out.txt",
527
- cwd,
528
- );
529
- expect(result).toContain("/tmp/out.txt");
530
- });
531
-
532
- test("detects path in append redirect", async () => {
533
- const result = await extractExternalPathsFromBashCommand(
534
- "echo hello >> /tmp/out.txt",
535
- cwd,
536
- );
537
- expect(result).toContain("/tmp/out.txt");
538
- });
539
-
540
- test("detects path in input redirect", async () => {
541
- const result = await extractExternalPathsFromBashCommand(
542
- "sort < /etc/hosts",
543
- cwd,
544
- );
545
- expect(result).toContain("/etc/hosts");
546
- });
547
-
548
- test("detects path in stderr redirect", async () => {
549
- const result = await extractExternalPathsFromBashCommand(
550
- "command 2>/tmp/errors.log",
551
- cwd,
552
- );
553
- expect(result).toContain("/tmp/errors.log");
554
- });
555
- });
556
-
557
- describe("deduplication", () => {
558
- test("returns deduplicated paths", async () => {
559
- const result = await extractExternalPathsFromBashCommand(
560
- "cat /etc/hosts; grep foo /etc/hosts",
561
- cwd,
562
- );
563
- const etcHostsCount = result.filter((p) => p === "/etc/hosts").length;
564
- expect(etcHostsCount).toBe(1);
565
- });
566
- });
567
-
568
- describe("command-aware extraction", () => {
569
- describe("sed", () => {
570
- test("issue #91 reproducer: sed address pattern is not flagged", async () => {
571
- const cmd = `sed -i '' '/source: "tool",/{/origin:/!s/source: "tool",/source: "tool",\n origin: "builtin",/;}' tests/tool-input-preview.test.ts`;
572
- const result = await extractExternalPathsFromBashCommand(cmd, cwd);
573
- expect(result).toHaveLength(0);
574
- });
575
-
576
- test("sed script is skipped but file argument is extracted", async () => {
577
- const result = await extractExternalPathsFromBashCommand(
578
- "sed 's/foo/bar/g' /etc/hosts",
579
- cwd,
580
- );
581
- expect(result).toContain("/etc/hosts");
582
- });
583
-
584
- test("sed address pattern starting with / is skipped", async () => {
585
- const result = await extractExternalPathsFromBashCommand(
586
- "sed '/pattern/d' /etc/hosts",
587
- cwd,
588
- );
589
- expect(result).toContain("/etc/hosts");
590
- expect(result).toHaveLength(1);
591
- });
592
-
593
- test("sed with only in-CWD file returns empty", async () => {
594
- const result = await extractExternalPathsFromBashCommand(
595
- "sed 's/foo/bar/' src/index.ts",
596
- cwd,
597
- );
598
- expect(result).toHaveLength(0);
599
- });
600
-
601
- test("sed -e: script consumed by flag, file extracted", async () => {
602
- const result = await extractExternalPathsFromBashCommand(
603
- "sed -e 's/foo/bar/' /etc/hosts",
604
- cwd,
605
- );
606
- expect(result).toContain("/etc/hosts");
607
- expect(result).toHaveLength(1);
608
- });
609
-
610
- test("sed -n: regular flag does not consume next arg", async () => {
611
- const result = await extractExternalPathsFromBashCommand(
612
- "sed -n '/pattern/p' /etc/hosts",
613
- cwd,
614
- );
615
- expect(result).toContain("/etc/hosts");
616
- expect(result).toHaveLength(1);
617
- });
618
-
619
- test("sed -f: script file is extracted as path", async () => {
620
- const result = await extractExternalPathsFromBashCommand(
621
- "sed -f /etc/sed-script.sed input.txt",
622
- cwd,
623
- );
624
- expect(result).toContain("/etc/sed-script.sed");
625
- expect(result).toHaveLength(1);
626
- });
627
-
628
- test("sed -i '': extension consumed, script skipped, file extracted", async () => {
629
- const result = await extractExternalPathsFromBashCommand(
630
- "sed -i '' 's/foo/bar/' /etc/hosts",
631
- cwd,
632
- );
633
- expect(result).toContain("/etc/hosts");
634
- expect(result).toHaveLength(1);
635
- });
636
- });
637
-
638
- describe("grep", () => {
639
- test("grep: pattern skipped, file extracted", async () => {
640
- const result = await extractExternalPathsFromBashCommand(
641
- "grep '/etc/' /var/log/syslog",
642
- cwd,
643
- );
644
- expect(result).toContain("/var/log/syslog");
645
- expect(result).toHaveLength(1);
646
- });
647
-
648
- test("grep -e: pattern consumed by flag, file extracted", async () => {
649
- const result = await extractExternalPathsFromBashCommand(
650
- "grep -e '/etc/' /var/log/syslog",
651
- cwd,
652
- );
653
- expect(result).toContain("/var/log/syslog");
654
- expect(result).toHaveLength(1);
655
- });
656
- });
657
-
658
- describe("awk", () => {
659
- test("awk: program skipped, file extracted", async () => {
660
- const result = await extractExternalPathsFromBashCommand(
661
- "awk '{print}' /etc/hosts",
662
- cwd,
663
- );
664
- expect(result).toContain("/etc/hosts");
665
- expect(result).toHaveLength(1);
666
- });
667
-
668
- test("awk -F: separator consumed, program skipped, file extracted", async () => {
669
- const result = await extractExternalPathsFromBashCommand(
670
- "awk -F: '{print $1}' /etc/passwd",
671
- cwd,
672
- );
673
- expect(result).toContain("/etc/passwd");
674
- expect(result).toHaveLength(1);
675
- });
676
- });
677
-
678
- describe("rg", () => {
679
- test("rg: pattern skipped, path extracted", async () => {
680
- const result = await extractExternalPathsFromBashCommand(
681
- "rg '/usr/local' /etc/profile.d/",
682
- cwd,
683
- );
684
- expect(result).toContain("/etc/profile.d");
685
- expect(result).toHaveLength(1);
686
- });
687
-
688
- test("rg -e: pattern consumed by flag, path extracted", async () => {
689
- const result = await extractExternalPathsFromBashCommand(
690
- "rg -e '/usr/local' /etc/profile.d/",
691
- cwd,
692
- );
693
- expect(result).toContain("/etc/profile.d");
694
- expect(result).toHaveLength(1);
695
- });
696
- });
697
-
698
- describe("sd", () => {
699
- test("sd: both pattern positionals skipped, file extracted", async () => {
700
- const result = await extractExternalPathsFromBashCommand(
701
- "sd '/usr/local/bin' '/opt/bin' /etc/profile",
702
- cwd,
703
- );
704
- expect(result).toContain("/etc/profile");
705
- expect(result).toHaveLength(1);
706
- });
707
-
708
- test("sd with only in-CWD file returns empty", async () => {
709
- const result = await extractExternalPathsFromBashCommand(
710
- "sd 'foo' 'bar' src/index.ts",
711
- cwd,
712
- );
713
- expect(result).toHaveLength(0);
714
- });
715
- });
716
-
717
- describe("unknown commands", () => {
718
- test("unknown command: all args go through generic extraction", async () => {
719
- const result = await extractExternalPathsFromBashCommand(
720
- "some-tool /etc/hosts",
721
- cwd,
722
- );
723
- expect(result).toContain("/etc/hosts");
724
- });
725
- });
726
-
727
- describe("edge cases", () => {
728
- test("full-path command invocation: /usr/bin/sed", async () => {
729
- const result = await extractExternalPathsFromBashCommand(
730
- "/usr/bin/sed 's/foo/bar/' /etc/hosts",
731
- cwd,
732
- );
733
- expect(result).toContain("/etc/hosts");
734
- expect(result).toHaveLength(1);
735
- });
736
-
737
- test("-- end-of-flags: all remaining args are positional files", async () => {
738
- const result = await extractExternalPathsFromBashCommand(
739
- "grep -- '/etc/' /var/log/syslog",
740
- cwd,
741
- );
742
- // After --, '/etc/' is the pattern positional, /var/log/syslog is a file
743
- expect(result).toContain("/var/log/syslog");
744
- expect(result).toHaveLength(1);
745
- });
746
-
747
- test("redirect target still extracted for pattern-first command", async () => {
748
- const result = await extractExternalPathsFromBashCommand(
749
- "sed 's/foo/bar/' input.txt > /tmp/output.txt",
750
- cwd,
751
- );
752
- expect(result).toContain("/tmp/output.txt");
753
- });
754
-
755
- test("pipeline: sed piped to cat with external path", async () => {
756
- const result = await extractExternalPathsFromBashCommand(
757
- "sed 's/foo/bar/' src/file.ts | cat /etc/hosts",
758
- cwd,
759
- );
760
- expect(result).toContain("/etc/hosts");
761
- expect(result).toHaveLength(1);
762
- });
763
-
764
- test("command substitution inside pattern-first command", async () => {
765
- const result = await extractExternalPathsFromBashCommand(
766
- "grep 'pattern' $(cat /etc/file-list)",
767
- cwd,
768
- );
769
- // /etc/file-list is an argument to cat inside command substitution
770
- expect(result).toContain("/etc/file-list");
771
- });
772
- });
773
-
774
- describe("known limitations", () => {
775
- test("sed -i without extension (GNU sed): /etc/hosts is missed (false negative)", async () => {
776
- // GNU sed treats -i as a flag with no argument, so 's/foo/bar/' is
777
- // the inline script and /etc/hosts is the input file. Our logic
778
- // treats -i as arg-consuming (correct for BSD sed -i ''), so it
779
- // consumes the script as the -i extension and /etc/hosts becomes
780
- // the first positional — which is skipped as the inline script.
781
- // This is a known false negative. The bash permission gate still
782
- // applies, so external access is not silently allowed.
783
- const result = await extractExternalPathsFromBashCommand(
784
- "sed -i 's/foo/bar/' /etc/hosts",
785
- cwd,
786
- );
787
- // Ideally this would detect /etc/hosts, but position tracking
788
- // treats it as the inline script. Assert current behavior so
789
- // a future fix can flip this expectation.
790
- expect(result).toHaveLength(0);
791
- });
792
- });
793
- });
794
-
795
- describe("regex patterns are not mistaken for paths", () => {
796
- test("grep -v with //.*pattern is not flagged", async () => {
797
- const result = await extractExternalPathsFromBashCommand(
798
- 'grep -n "glob" src/foo.ts 2>/dev/null | grep -v "//.*glob\\|globalConfig" | head -30',
799
- cwd,
800
- );
801
- expect(result).toHaveLength(0);
802
- });
803
-
804
- test("grep -v with //.*pattern without backslash-pipe is not flagged", async () => {
805
- const result = await extractExternalPathsFromBashCommand(
806
- 'grep -v "//.*foo" file.txt',
807
- cwd,
808
- );
809
- expect(result).toHaveLength(0);
810
- });
811
-
812
- test("grep with backslash-pipe alternation is not flagged", async () => {
813
- const result = await extractExternalPathsFromBashCommand(
814
- 'grep "foo\\|bar\\|baz" src/file.ts',
815
- cwd,
816
- );
817
- expect(result).toHaveLength(0);
818
- });
819
-
820
- test("grep -E with ^/ anchored regex is not flagged", async () => {
821
- const result = await extractExternalPathsFromBashCommand(
822
- 'grep -E "^/usr/bin" file.txt',
823
- cwd,
824
- );
825
- expect(result).toHaveLength(0);
826
- });
827
-
828
- test("sed with regex containing slashes is not flagged", async () => {
829
- const result = await extractExternalPathsFromBashCommand(
830
- 'sed "s/foo.*/bar/g" file.txt',
831
- cwd,
832
- );
833
- expect(result).toHaveLength(0);
834
- });
835
-
836
- test("real external paths are still detected alongside regex args", async () => {
837
- const result = await extractExternalPathsFromBashCommand(
838
- 'grep -v "//.*pattern" /etc/hosts',
839
- cwd,
840
- );
841
- expect(result).toContain("/etc/hosts");
842
- });
843
- });
844
-
845
- describe("leading cd prefix", () => {
846
- test("regression: cd to subdir with relative path traversing back into cwd is not flagged", async () => {
847
- // Real-world command that triggered a false-positive external-directory
848
- // prompt. The relative path .pi/../../../.pi/skills/... resolves inside
849
- // cwd when resolved from the cd target, but outside cwd when resolved
850
- // from cwd itself.
851
- const result = await extractExternalPathsFromBashCommand(
852
- 'cd /projects/my-app/packages/sub && grep -n "pattern" .pi/../../../.pi/skills/pkg/SKILL.md',
853
- cwd,
854
- );
855
- expect(result).toHaveLength(0);
856
- });
857
-
858
- test("cd to subdir: still flags genuinely external paths after cd", async () => {
859
- const result = await extractExternalPathsFromBashCommand(
860
- "cd /projects/my-app/packages/sub && cat /etc/hosts",
861
- cwd,
862
- );
863
- expect(result).toContain("/etc/hosts");
864
- });
865
-
866
- test("cd to subdir: relative path that stays inside cwd is not flagged", async () => {
867
- const result = await extractExternalPathsFromBashCommand(
868
- "cd /projects/my-app/src && cat ../README.md",
869
- cwd,
870
- );
871
- expect(result).toHaveLength(0);
872
- });
873
-
874
- test("cd to external dir: subsequent paths resolve against the (external) effective directory", async () => {
875
- // The effective directory is tracked faithfully: `cd /tmp` makes /tmp the
876
- // base, so the cd target itself is flagged AND ../etc/hosts resolves to
877
- // /etc/hosts (both outside cwd).
878
- const result = await extractExternalPathsFromBashCommand(
879
- "cd /tmp && cat ../etc/hosts",
880
- cwd,
881
- );
882
- expect(result).toContain("/tmp");
883
- expect(result).toContain("/etc/hosts");
884
- });
885
-
886
- test("cd with relative target: resolves inside cwd", async () => {
887
- const result = await extractExternalPathsFromBashCommand(
888
- 'cd packages/sub && grep -n "x" .pi/../../../.pi/skills/pkg/SKILL.md',
889
- cwd,
890
- );
891
- expect(result).toHaveLength(0);
892
- });
893
-
894
- test("no cd prefix: ../ path that escapes cwd is flagged", async () => {
895
- // Without the cd prefix, the path resolves against cwd and escapes.
896
- const result = await extractExternalPathsFromBashCommand(
897
- 'grep -n "pattern" .pi/../../../.pi/skills/pkg/SKILL.md',
898
- cwd,
899
- );
900
- expect(result.length).toBeGreaterThan(0);
901
- });
902
-
903
- test("sequential fold: a cd that is not the first command still updates the base", async () => {
904
- // The current-shell `cd` folds even though it is not the first command;
905
- // ../../outside.txt resolves against /projects/my-app/src → /projects/outside.txt.
906
- const result = await extractExternalPathsFromBashCommand(
907
- "echo hello && cd /projects/my-app/src && cat ../../outside.txt",
908
- cwd,
909
- );
910
- expect(result).toContain("/projects/outside.txt");
911
- });
912
-
913
- test("cd with semicolon separator", async () => {
914
- const result = await extractExternalPathsFromBashCommand(
915
- "cd /projects/my-app/src ; cat ../README.md",
916
- cwd,
917
- );
918
- expect(result).toHaveLength(0);
919
- });
920
- });
921
- });
922
-
923
- describe("formatBashExternalDirectoryAskPrompt", () => {
924
- test("includes command, external paths, and CWD", () => {
925
- const result = formatBashExternalDirectoryAskPrompt(
926
- "cat /etc/hosts",
927
- [{ path: "/etc/hosts" }],
928
- "/projects/my-app",
929
- );
930
- expect(result).toContain("cat /etc/hosts");
931
- expect(result).toContain("/etc/hosts");
932
- expect(result).toContain("/projects/my-app");
933
- });
934
-
935
- test("includes agent name when provided", () => {
936
- const result = formatBashExternalDirectoryAskPrompt(
937
- "cat /etc/hosts",
938
- [{ path: "/etc/hosts" }],
939
- "/projects/my-app",
940
- "my-agent",
941
- );
942
- expect(result).toContain("my-agent");
943
- });
944
-
945
- test("shows multiple external paths", () => {
946
- const result = formatBashExternalDirectoryAskPrompt(
947
- "diff /etc/hosts /var/log/syslog",
948
- [{ path: "/etc/hosts" }, { path: "/var/log/syslog" }],
949
- "/projects/my-app",
950
- );
951
- expect(result).toContain("/etc/hosts");
952
- expect(result).toContain("/var/log/syslog");
953
- });
954
-
955
- test("discloses the resolved target per path when it differs", () => {
956
- const result = formatBashExternalDirectoryAskPrompt(
957
- "cat demo-symlink-passwd /etc/hosts",
958
- [
959
- { path: "demo-symlink-passwd", resolvedPath: "/etc/passwd" },
960
- { path: "/etc/hosts" },
961
- ],
962
- "/projects/my-app",
963
- );
964
- expect(result).toBe(
965
- "Current agent requested bash command 'cat demo-symlink-passwd /etc/hosts' which references path(s) outside working directory '/projects/my-app': demo-symlink-passwd (resolves to '/etc/passwd'), /etc/hosts. Allow this external directory access?",
966
- );
967
- });
968
- });
969
-
970
- describe("Windows drive-letter paths (win32 semantics)", () => {
971
- const windowsCwd = "C:/projects/app";
972
-
973
- async function extractWin32(command: string): Promise<string[]> {
974
- return extractWithNormalizer(
975
- command,
976
- new PathNormalizer("win32", windowsCwd),
977
- );
978
- }
979
-
980
- test("forward-slash drive path outside CWD is flagged", async () => {
981
- const result = await extractWin32("cat C:/Windows/win.ini");
982
- expect(result).not.toHaveLength(0);
983
- });
984
-
985
- test("different drive letter outside CWD is flagged", async () => {
986
- const result = await extractWin32("cat D:/secrets/password.txt");
987
- expect(result).not.toHaveLength(0);
988
- });
989
-
990
- test("drive path inside CWD is not flagged (known base)", async () => {
991
- const result = await extractWin32("cat C:/projects/app/inside.txt");
992
- expect(result).toHaveLength(0);
993
- });
994
-
995
- test("drive path inside CWD is not flagged after non-literal cd (unknown base)", async () => {
996
- // Before the isRelativeCandidate conversion, the hand-rolled startsWith("/")
997
- // check treats C:/ as relative on win32, so the unknown-base conservative
998
- // branch fires and over-flags an inside-CWD drive path.
999
- // After the conversion (!normalizer.isAbsolute), C:/ is absolute on win32
1000
- // and routes to the resolved branch with its inside-CWD check.
1001
- const result = await extractWin32(
1002
- 'cd "$D" && cat C:/projects/app/inside.txt',
1003
- );
1004
- expect(result).toHaveLength(0);
1005
- });
1006
- });
1007
-
1008
- describe("bash external-directory denial messages (centralized)", () => {
1009
- test("denial message includes command, paths, and extension tag", () => {
1010
- const result = formatDenyReason({
1011
- kind: "bash_external_directory",
1012
- command: "cat /etc/hosts",
1013
- externalPaths: [{ path: "/etc/hosts" }],
1014
- cwd: "/projects/my-app",
1015
- });
1016
- expect(result).toContain("cat /etc/hosts");
1017
- expect(result).toContain("/etc/hosts");
1018
- expect(result).toContain("/projects/my-app");
1019
- expect(result).toContain("[pi-permission-system]");
1020
- expect(result).not.toContain("Hard stop");
1021
- });
1022
- });