@gotgenes/pi-permission-system 18.1.1 → 18.1.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 (122) hide show
  1. package/CHANGELOG.md +16 -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/path-normalizer.ts +105 -2
  8. package/test/access-intent/access-path.test.ts +0 -277
  9. package/test/access-intent/bash/node-text.test.ts +0 -148
  10. package/test/access-intent/bash/parser.test.ts +0 -19
  11. package/test/access-intent/bash/program.test.ts +0 -673
  12. package/test/access-intent/bash/token-classification.test.ts +0 -363
  13. package/test/access-intent/bash/token-collection.test.ts +0 -300
  14. package/test/active-agent.test.ts +0 -155
  15. package/test/async-cache.test.ts +0 -48
  16. package/test/bash-arity.test.ts +0 -144
  17. package/test/bash-external-directory.test.ts +0 -1022
  18. package/test/builtin-tool-input-formatters.test.ts +0 -109
  19. package/test/canonicalize-path.test.ts +0 -119
  20. package/test/composition-root.test.ts +0 -698
  21. package/test/config-loader.test.ts +0 -740
  22. package/test/config-modal.test.ts +0 -320
  23. package/test/config-paths.test.ts +0 -83
  24. package/test/config-pipeline.test.ts +0 -90
  25. package/test/config-reporter.test.ts +0 -147
  26. package/test/config-store.test.ts +0 -466
  27. package/test/decision-audit.test.ts +0 -72
  28. package/test/decision-reporter.test.ts +0 -112
  29. package/test/denial-messages.test.ts +0 -714
  30. package/test/detect-permissive-bash-fallback.test.ts +0 -56
  31. package/test/expand-home.test.ts +0 -93
  32. package/test/extension-config.test.ts +0 -129
  33. package/test/extension-paths.test.ts +0 -108
  34. package/test/forwarded-permissions/io.test.ts +0 -251
  35. package/test/forwarding-manager.test.ts +0 -200
  36. package/test/handlers/before-agent-start.test.ts +0 -314
  37. package/test/handlers/external-directory-integration.test.ts +0 -515
  38. package/test/handlers/external-directory-session-dedup.test.ts +0 -175
  39. package/test/handlers/external-directory-symlink-acceptance.test.ts +0 -167
  40. package/test/handlers/gates/bash-command-metamorphic.test.ts +0 -88
  41. package/test/handlers/gates/bash-command.test.ts +0 -257
  42. package/test/handlers/gates/bash-external-directory.test.ts +0 -268
  43. package/test/handlers/gates/bash-path.test.ts +0 -346
  44. package/test/handlers/gates/candidate-check.test.ts +0 -52
  45. package/test/handlers/gates/external-directory-messages.test.ts +0 -85
  46. package/test/handlers/gates/external-directory-policy.test.ts +0 -134
  47. package/test/handlers/gates/external-directory.test.ts +0 -267
  48. package/test/handlers/gates/helpers.test.ts +0 -165
  49. package/test/handlers/gates/path.test.ts +0 -369
  50. package/test/handlers/gates/runner.test.ts +0 -408
  51. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +0 -176
  52. package/test/handlers/gates/skill-input.test.ts +0 -128
  53. package/test/handlers/gates/skill-read.test.ts +0 -161
  54. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +0 -356
  55. package/test/handlers/gates/tool.test.ts +0 -244
  56. package/test/handlers/input-events.test.ts +0 -168
  57. package/test/handlers/input.test.ts +0 -199
  58. package/test/handlers/lifecycle.test.ts +0 -221
  59. package/test/handlers/tool-call-boundary.test.ts +0 -145
  60. package/test/handlers/tool-call-events.test.ts +0 -277
  61. package/test/handlers/tool-call.test.ts +0 -395
  62. package/test/handlers/validate-requested-tool.test.ts +0 -92
  63. package/test/helpers/external-directory-fixtures.ts +0 -269
  64. package/test/helpers/gate-fixtures.ts +0 -316
  65. package/test/helpers/handler-fixtures.ts +0 -335
  66. package/test/helpers/make-fake-pi.ts +0 -100
  67. package/test/helpers/manager-harness.ts +0 -112
  68. package/test/helpers/session-fixtures.ts +0 -199
  69. package/test/input-normalizer.test.ts +0 -325
  70. package/test/logging.test.ts +0 -51
  71. package/test/mcp-targets.test.ts +0 -233
  72. package/test/node-modules-discovery.test.ts +0 -97
  73. package/test/normalize.test.ts +0 -247
  74. package/test/path-containment.test.ts +0 -161
  75. package/test/path-normalization.test.ts +0 -233
  76. package/test/path-normalizer.test.ts +0 -196
  77. package/test/path-surfaces.test.ts +0 -55
  78. package/test/pattern-suggest.test.ts +0 -248
  79. package/test/permission-dialog.test.ts +0 -205
  80. package/test/permission-event-rpc.test.ts +0 -560
  81. package/test/permission-events.test.ts +0 -400
  82. package/test/permission-forwarder.test.ts +0 -370
  83. package/test/permission-forwarding.test.ts +0 -315
  84. package/test/permission-gate.test.ts +0 -269
  85. package/test/permission-manager-unified.test.ts +0 -3714
  86. package/test/permission-merge.test.ts +0 -61
  87. package/test/permission-prompter.test.ts +0 -518
  88. package/test/permission-prompts.test.ts +0 -363
  89. package/test/permission-resolver.test.ts +0 -277
  90. package/test/permission-session.test.ts +0 -404
  91. package/test/permission-ui-prompt.test.ts +0 -146
  92. package/test/permissions-service.test.ts +0 -192
  93. package/test/pi-infrastructure-read.test.ts +0 -432
  94. package/test/policy-loader.test.ts +0 -561
  95. package/test/prompting-gateway.test.ts +0 -231
  96. package/test/rule.test.ts +0 -650
  97. package/test/safe-system-paths.test.ts +0 -46
  98. package/test/scope-merge.test.ts +0 -116
  99. package/test/service-lifecycle.test.ts +0 -163
  100. package/test/service.test.ts +0 -261
  101. package/test/session-approval.test.ts +0 -75
  102. package/test/session-logger.test.ts +0 -200
  103. package/test/session-rules.test.ts +0 -321
  104. package/test/session-start.test.ts +0 -112
  105. package/test/skill-prompt-sanitizer.test.ts +0 -418
  106. package/test/status.test.ts +0 -10
  107. package/test/subagent-context.test.ts +0 -372
  108. package/test/subagent-lifecycle-events.test.ts +0 -132
  109. package/test/subagent-registry.test.ts +0 -145
  110. package/test/synthesize.test.ts +0 -302
  111. package/test/system-prompt-sanitizer.test.ts +0 -382
  112. package/test/tool-access-extractor-registry.test.ts +0 -77
  113. package/test/tool-input-formatter-registry.test.ts +0 -75
  114. package/test/tool-input-path.test.ts +0 -84
  115. package/test/tool-input-preview.test.ts +0 -129
  116. package/test/tool-input-prompt-formatters.test.ts +0 -115
  117. package/test/tool-preview-formatter.test.ts +0 -458
  118. package/test/tool-registry.test.ts +0 -197
  119. package/test/value-guards.test.ts +0 -193
  120. package/test/wildcard-matcher.test.ts +0 -424
  121. package/test/yaml-frontmatter.test.ts +0 -91
  122. 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
- });