@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,363 +0,0 @@
1
- import { describe, expect, test } from "vitest";
2
-
3
- import {
4
- classifyPromotedRuleCandidate,
5
- classifyTokenAsPathCandidate,
6
- classifyTokenAsRuleCandidate,
7
- } from "#src/access-intent/bash/token-classification";
8
-
9
- // ── Shared rejection behaviour ─────────────────────────────────────────────
10
- //
11
- // Both classifiers delegate to the private `rejectNonPathToken` predicate for
12
- // the seven shared rejection cases tested below. Testing via both exports
13
- // pins that predicate through each caller.
14
-
15
- describe("classifyTokenAsPathCandidate", () => {
16
- describe("shared rejection: rejectNonPathToken", () => {
17
- test("empty string → null", () => {
18
- expect(classifyTokenAsPathCandidate("")).toBeNull();
19
- });
20
-
21
- test("flag (leading dash) → null", () => {
22
- expect(classifyTokenAsPathCandidate("-r")).toBeNull();
23
- expect(classifyTokenAsPathCandidate("--recursive")).toBeNull();
24
- });
25
-
26
- test("env assignment (= before any /) → null", () => {
27
- expect(classifyTokenAsPathCandidate("FOO=/bar")).toBeNull();
28
- expect(classifyTokenAsPathCandidate("HOME=/home/user")).toBeNull();
29
- });
30
-
31
- test("env-like token where = comes after / is NOT rejected as assignment", () => {
32
- // /foo=bar: slashIndex (0) < eqIndex (4) → not an assignment → continues
33
- // Starts with /, so path candidate accepts it.
34
- expect(classifyTokenAsPathCandidate("/foo=bar")).toBe("/foo=bar");
35
- });
36
-
37
- test("URL → null", () => {
38
- expect(classifyTokenAsPathCandidate("https://example.com")).toBeNull();
39
- expect(classifyTokenAsPathCandidate("http://localhost:3000")).toBeNull();
40
- expect(classifyTokenAsPathCandidate("file:///tmp/foo")).toBeNull();
41
- expect(
42
- classifyTokenAsPathCandidate("git+ssh://github.com/a/b"),
43
- ).toBeNull();
44
- });
45
-
46
- test("@scope/package → null", () => {
47
- expect(classifyTokenAsPathCandidate("@foo/bar")).toBeNull();
48
- expect(classifyTokenAsPathCandidate("@scope/pkg")).toBeNull();
49
- });
50
-
51
- test("@/ prefix is NOT rejected (it looks like an absolute-rooted scoped path)", () => {
52
- // @/ passes the @ guard; then for path candidate it doesn't start with /
53
- // or ~/, and doesn't contain .., so it returns null anyway from the
54
- // acceptance gate — but the rejection is not due to the @ guard.
55
- // This test documents that @/ is not rejected by the shared rejection.
56
- // The path classifier then rejects it for not matching any acceptance shape.
57
- expect(classifyTokenAsPathCandidate("@/foo/bar")).toBeNull();
58
- });
59
-
60
- test("bare-slash token → null", () => {
61
- expect(classifyTokenAsPathCandidate("/")).toBeNull();
62
- expect(classifyTokenAsPathCandidate("//")).toBeNull();
63
- expect(classifyTokenAsPathCandidate("///")).toBeNull();
64
- });
65
-
66
- test("regex metacharacters → null", () => {
67
- // REGEX_METACHAR_PATTERN: .*, .+, \|, \(, \), [...], ^/
68
- expect(classifyTokenAsPathCandidate("foo.*")).toBeNull();
69
- expect(classifyTokenAsPathCandidate("bar.+")).toBeNull();
70
- expect(classifyTokenAsPathCandidate("a\\|b")).toBeNull();
71
- expect(classifyTokenAsPathCandidate("\\(group\\)")).toBeNull();
72
- expect(classifyTokenAsPathCandidate("[abc]")).toBeNull();
73
- expect(classifyTokenAsPathCandidate("^/start")).toBeNull();
74
- });
75
- });
76
-
77
- describe("path-candidate acceptance gate", () => {
78
- test("absolute path (starts with /) → returned as-is", () => {
79
- expect(classifyTokenAsPathCandidate("/etc/hosts")).toBe("/etc/hosts");
80
- expect(classifyTokenAsPathCandidate("/tmp")).toBe("/tmp");
81
- expect(classifyTokenAsPathCandidate("/home/user/file.txt")).toBe(
82
- "/home/user/file.txt",
83
- );
84
- });
85
-
86
- test("home-relative path (starts with ~/) → returned as-is", () => {
87
- expect(classifyTokenAsPathCandidate("~/Documents")).toBe("~/Documents");
88
- expect(classifyTokenAsPathCandidate("~/.ssh/config")).toBe(
89
- "~/.ssh/config",
90
- );
91
- });
92
-
93
- test("parent-traversal (contains ..) → returned as-is", () => {
94
- expect(classifyTokenAsPathCandidate("../../etc/passwd")).toBe(
95
- "../../etc/passwd",
96
- );
97
- expect(classifyTokenAsPathCandidate("../foo")).toBe("../foo");
98
- expect(classifyTokenAsPathCandidate("..")).toBe("..");
99
- });
100
-
101
- test("plain word with no path shape → null", () => {
102
- expect(classifyTokenAsPathCandidate("hello")).toBeNull();
103
- expect(classifyTokenAsPathCandidate("myfile.txt")).toBeNull();
104
- });
105
-
106
- test("dot-file (starts with .) → null (strict path gate)", () => {
107
- // Path candidate does NOT accept dot-files; rule candidate does.
108
- expect(classifyTokenAsPathCandidate(".env")).toBeNull();
109
- expect(classifyTokenAsPathCandidate(".gitignore")).toBeNull();
110
- });
111
-
112
- test("relative path with / but no leading / or ~/ → null (strict path gate)", () => {
113
- // Path candidate does NOT accept bare relative paths; rule candidate does.
114
- expect(classifyTokenAsPathCandidate("src/foo.ts")).toBeNull();
115
- expect(classifyTokenAsPathCandidate("./build")).toBeNull();
116
- });
117
- });
118
-
119
- describe("Windows drive-letter acceptance gate", () => {
120
- test("forward-slash drive path → returned as-is", () => {
121
- expect(classifyTokenAsPathCandidate("C:/Windows/win.ini")).toBe(
122
- "C:/Windows/win.ini",
123
- );
124
- expect(classifyTokenAsPathCandidate("D:/secrets/password.txt")).toBe(
125
- "D:/secrets/password.txt",
126
- );
127
- });
128
-
129
- test("backslash drive path → returned as-is", () => {
130
- expect(classifyTokenAsPathCandidate("C:\\Windows\\win.ini")).toBe(
131
- "C:\\Windows\\win.ini",
132
- );
133
- expect(classifyTokenAsPathCandidate("D:\\secrets\\password.txt")).toBe(
134
- "D:\\secrets\\password.txt",
135
- );
136
- });
137
-
138
- test("lowercase drive letter → returned as-is", () => {
139
- expect(classifyTokenAsPathCandidate("c:/foo")).toBe("c:/foo");
140
- });
141
-
142
- test("single-letter scheme with double-slash (c://x) → null (URL_PATTERN fires first)", () => {
143
- // c:// matches URL_PATTERN before the drive-letter check runs.
144
- expect(classifyTokenAsPathCandidate("c://x")).toBeNull();
145
- });
146
-
147
- test("drive-relative path without separator (C:foo) → null", () => {
148
- // No / or \ after the colon — not an absolute drive path per node:path.
149
- expect(classifyTokenAsPathCandidate("C:foo")).toBeNull();
150
- });
151
- });
152
- });
153
-
154
- describe("classifyTokenAsRuleCandidate", () => {
155
- describe("shared rejection: rejectNonPathToken", () => {
156
- test("empty string → null", () => {
157
- expect(classifyTokenAsRuleCandidate("")).toBeNull();
158
- });
159
-
160
- test("flag (leading dash) → null", () => {
161
- expect(classifyTokenAsRuleCandidate("-r")).toBeNull();
162
- expect(classifyTokenAsRuleCandidate("--recursive")).toBeNull();
163
- });
164
-
165
- test("env assignment (= before any /) → null", () => {
166
- expect(classifyTokenAsRuleCandidate("FOO=/bar")).toBeNull();
167
- expect(classifyTokenAsRuleCandidate("HOME=/home/user")).toBeNull();
168
- });
169
-
170
- test("env-like token where = comes after / is NOT rejected as assignment", () => {
171
- // /foo=bar: slashIndex (0) < eqIndex (4) → not an assignment → continues.
172
- // Contains /, so rule candidate accepts it.
173
- expect(classifyTokenAsRuleCandidate("/foo=bar")).toBe("/foo=bar");
174
- });
175
-
176
- test("URL → null", () => {
177
- expect(classifyTokenAsRuleCandidate("https://example.com")).toBeNull();
178
- expect(classifyTokenAsRuleCandidate("http://localhost:3000")).toBeNull();
179
- expect(classifyTokenAsRuleCandidate("file:///tmp/foo")).toBeNull();
180
- });
181
-
182
- test("@scope/package → null", () => {
183
- expect(classifyTokenAsRuleCandidate("@foo/bar")).toBeNull();
184
- expect(classifyTokenAsRuleCandidate("@scope/pkg")).toBeNull();
185
- });
186
-
187
- test("bare-slash token → null", () => {
188
- expect(classifyTokenAsRuleCandidate("/")).toBeNull();
189
- expect(classifyTokenAsRuleCandidate("//")).toBeNull();
190
- });
191
-
192
- test("regex metacharacters → null", () => {
193
- expect(classifyTokenAsRuleCandidate("foo.*")).toBeNull();
194
- expect(classifyTokenAsRuleCandidate("bar.+")).toBeNull();
195
- expect(classifyTokenAsRuleCandidate("a\\|b")).toBeNull();
196
- expect(classifyTokenAsRuleCandidate("[abc]")).toBeNull();
197
- expect(classifyTokenAsRuleCandidate("^/start")).toBeNull();
198
- });
199
- });
200
-
201
- describe("rule-candidate acceptance gate (broader than path)", () => {
202
- test("absolute path (starts with /) → returned as-is", () => {
203
- expect(classifyTokenAsRuleCandidate("/etc/hosts")).toBe("/etc/hosts");
204
- });
205
-
206
- test("home-relative path (starts with ~/) → returned as-is", () => {
207
- expect(classifyTokenAsRuleCandidate("~/Documents")).toBe("~/Documents");
208
- });
209
-
210
- test("parent-traversal (contains ..) → returned as-is", () => {
211
- expect(classifyTokenAsRuleCandidate("../foo")).toBe("../foo");
212
- expect(classifyTokenAsRuleCandidate("..")).toBe("..");
213
- });
214
-
215
- test("dot-file (starts with .) → returned as-is", () => {
216
- // Rule candidate accepts dot-files; path candidate does not.
217
- expect(classifyTokenAsRuleCandidate(".env")).toBe(".env");
218
- expect(classifyTokenAsRuleCandidate(".gitignore")).toBe(".gitignore");
219
- });
220
-
221
- test("current-dir relative (starts with ./) → returned as-is", () => {
222
- expect(classifyTokenAsRuleCandidate("./src")).toBe("./src");
223
- expect(classifyTokenAsRuleCandidate("./build/output.js")).toBe(
224
- "./build/output.js",
225
- );
226
- });
227
-
228
- test("relative path containing / → returned as-is", () => {
229
- // Rule candidate accepts any token with / (not already rejected).
230
- expect(classifyTokenAsRuleCandidate("src/foo.ts")).toBe("src/foo.ts");
231
- expect(classifyTokenAsRuleCandidate("packages/pi-foo/index.ts")).toBe(
232
- "packages/pi-foo/index.ts",
233
- );
234
- });
235
-
236
- test("plain word with no path shape → null", () => {
237
- expect(classifyTokenAsRuleCandidate("hello")).toBeNull();
238
- expect(classifyTokenAsRuleCandidate("myfile.txt")).toBeNull();
239
- });
240
- });
241
-
242
- describe("Windows drive-letter acceptance gate", () => {
243
- test("forward-slash drive path → returned as-is", () => {
244
- // Forward-slash form was already accepted via token.includes("/").
245
- // The explicit branch makes it first-class and order-independent.
246
- expect(classifyTokenAsRuleCandidate("C:/Windows/win.ini")).toBe(
247
- "C:/Windows/win.ini",
248
- );
249
- });
250
-
251
- test("backslash drive path → returned as-is (new: no forward slash)", () => {
252
- // Previously dropped by both classifiers; the backslash form has no /
253
- // so the includes("/") branch could not catch it.
254
- expect(classifyTokenAsRuleCandidate("D:\\secrets\\password.txt")).toBe(
255
- "D:\\secrets\\password.txt",
256
- );
257
- expect(classifyTokenAsRuleCandidate("C:\\Windows\\win.ini")).toBe(
258
- "C:\\Windows\\win.ini",
259
- );
260
- });
261
-
262
- test("lowercase drive letter (backslash) → returned as-is", () => {
263
- expect(classifyTokenAsRuleCandidate("c:\\foo")).toBe("c:\\foo");
264
- });
265
-
266
- test("drive-relative path without separator (C:foo) → null", () => {
267
- expect(classifyTokenAsRuleCandidate("C:foo")).toBeNull();
268
- });
269
- });
270
-
271
- describe("rule-vs-path divergence", () => {
272
- const dotFiles = [".env", ".gitignore", ".eslintrc"];
273
- const relPaths = ["src/index.ts", "lib/utils.js", "config/settings.json"];
274
-
275
- for (const tok of dotFiles) {
276
- test(`dot-file "${tok}": rule accepts, path rejects`, () => {
277
- expect(classifyTokenAsRuleCandidate(tok)).toBe(tok);
278
- expect(classifyTokenAsPathCandidate(tok)).toBeNull();
279
- });
280
- }
281
-
282
- for (const tok of relPaths) {
283
- test(`relative path "${tok}": rule accepts, path rejects`, () => {
284
- expect(classifyTokenAsRuleCandidate(tok)).toBe(tok);
285
- expect(classifyTokenAsPathCandidate(tok)).toBeNull();
286
- });
287
- }
288
-
289
- const sharedAccepted = ["/etc/hosts", "~/docs", "../sibling"];
290
- for (const tok of sharedAccepted) {
291
- test(`"${tok}": both classifiers accept`, () => {
292
- expect(classifyTokenAsRuleCandidate(tok)).toBe(tok);
293
- expect(classifyTokenAsPathCandidate(tok)).toBe(tok);
294
- });
295
- }
296
-
297
- const winDrivePaths = [
298
- "C:/Windows/win.ini",
299
- "D:\\secrets\\password.txt",
300
- "c:/foo",
301
- ];
302
- for (const tok of winDrivePaths) {
303
- test(`Windows drive path "${tok}": both classifiers accept`, () => {
304
- expect(classifyTokenAsRuleCandidate(tok)).toBe(tok);
305
- expect(classifyTokenAsPathCandidate(tok)).toBe(tok);
306
- });
307
- }
308
-
309
- const sharedRejected = ["hello", "--flag", "FOO=/bar", "https://x.com"];
310
- for (const tok of sharedRejected) {
311
- test(`"${tok}": both classifiers reject`, () => {
312
- expect(classifyTokenAsRuleCandidate(tok)).toBeNull();
313
- expect(classifyTokenAsPathCandidate(tok)).toBeNull();
314
- });
315
- }
316
- });
317
- });
318
-
319
- describe("classifyPromotedRuleCandidate", () => {
320
- test("shape-eligible bare token promoted when predicate returns true", () => {
321
- expect(classifyPromotedRuleCandidate("id_rsa", () => true)).toBe("id_rsa");
322
- });
323
-
324
- test("shape-eligible bare token rejected when predicate returns false", () => {
325
- expect(classifyPromotedRuleCandidate("id_rsa", () => false)).toBeNull();
326
- });
327
-
328
- test("predicate receives the raw token", () => {
329
- const isPromotable = (token: string): boolean => token === "key.pem";
330
- expect(classifyPromotedRuleCandidate("key.pem", isPromotable)).toBe(
331
- "key.pem",
332
- );
333
- expect(classifyPromotedRuleCandidate("other.pem", isPromotable)).toBeNull();
334
- });
335
-
336
- describe("shared rejection still applies regardless of the predicate", () => {
337
- test("flag (leading dash) → null", () => {
338
- expect(classifyPromotedRuleCandidate("-r", () => true)).toBeNull();
339
- });
340
-
341
- test("env assignment → null", () => {
342
- expect(classifyPromotedRuleCandidate("FOO=/bar", () => true)).toBeNull();
343
- });
344
-
345
- test("URL → null", () => {
346
- expect(
347
- classifyPromotedRuleCandidate("https://example.com", () => true),
348
- ).toBeNull();
349
- });
350
-
351
- test("@scope/package → null", () => {
352
- expect(classifyPromotedRuleCandidate("@foo/bar", () => true)).toBeNull();
353
- });
354
-
355
- test("regex metacharacters → null", () => {
356
- expect(classifyPromotedRuleCandidate("foo.*", () => true)).toBeNull();
357
- });
358
-
359
- test("empty string → null", () => {
360
- expect(classifyPromotedRuleCandidate("", () => true)).toBeNull();
361
- });
362
- });
363
- });
@@ -1,300 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import type { TSNode } from "#src/access-intent/bash/parser";
3
- import { getParser } from "#src/access-intent/bash/parser";
4
- import {
5
- collectCommandTokens,
6
- collectPathCandidateTokens,
7
- collectRedirectTokens,
8
- extractCommandName,
9
- } from "#src/access-intent/bash/token-collection";
10
-
11
- // ── Helpers ───────────────────────────────────────────────────────────────────
12
-
13
- /** Depth-first search for the first node of the given type. */
14
- function findNode(node: TSNode, type: string): TSNode | null {
15
- if (node.type === type) return node;
16
- for (let i = 0; i < node.childCount; i++) {
17
- const child = node.child(i);
18
- if (!child) continue;
19
- const found = findNode(child, type);
20
- if (found) return found;
21
- }
22
- return null;
23
- }
24
-
25
- /** Parse a bash snippet and return the first `command` node. */
26
- async function parseCommandNode(cmd: string): Promise<{
27
- node: TSNode;
28
- tree: { rootNode: TSNode; delete(): void };
29
- }> {
30
- const parser = await getParser();
31
- const tree = parser.parse(cmd);
32
- if (!tree) throw new Error("parser.parse returned null");
33
- const node = findNode(tree.rootNode, "command");
34
- if (!node) throw new Error(`no command node found in: ${cmd}`);
35
- return { node, tree };
36
- }
37
-
38
- /** Parse a bash snippet and return the first `file_redirect` node. */
39
- async function parseRedirectNode(cmd: string): Promise<{
40
- node: TSNode;
41
- tree: { rootNode: TSNode; delete(): void };
42
- }> {
43
- const parser = await getParser();
44
- const tree = parser.parse(cmd);
45
- if (!tree) throw new Error("parser.parse returned null");
46
- const node = findNode(tree.rootNode, "file_redirect");
47
- if (!node) throw new Error(`no file_redirect node found in: ${cmd}`);
48
- return { node, tree };
49
- }
50
-
51
- // ── extractCommandName ────────────────────────────────────────────────────────
52
-
53
- describe("extractCommandName", () => {
54
- it("returns the basename for a bare command", async () => {
55
- const { node, tree } = await parseCommandNode("sed 's/x/y/' file.txt");
56
- try {
57
- expect(extractCommandName(node)).toBe("sed");
58
- } finally {
59
- tree.delete();
60
- }
61
- });
62
-
63
- it("strips the directory prefix from an absolute command path", async () => {
64
- const { node, tree } = await parseCommandNode(
65
- "/usr/bin/sed 's/x/y/' file.txt",
66
- );
67
- try {
68
- expect(extractCommandName(node)).toBe("sed");
69
- } finally {
70
- tree.delete();
71
- }
72
- });
73
-
74
- it("returns the substitution text when the command name is a command substitution", async () => {
75
- // $(which sed) parses with a command_name child whose text is "$(which sed)";
76
- // resolveNodeText returns that text, so extractCommandName returns its basename.
77
- // PATTERN_FIRST_COMMANDS.get("$(which sed)") returns undefined, so
78
- // collectCommandTokens falls back to generic collection — correct behaviour.
79
- const { node, tree } = await parseCommandNode(
80
- "$(which sed) 's/x/y/' file.txt",
81
- );
82
- try {
83
- expect(extractCommandName(node)).toBe("$(which sed)");
84
- } finally {
85
- tree.delete();
86
- }
87
- });
88
- });
89
-
90
- // ── collectCommandTokens — pattern-first commands ─────────────────────────────
91
-
92
- describe("collectCommandTokens — pattern-first commands", () => {
93
- it("sed: skips the first positional (inline pattern) and collects the rest", async () => {
94
- const { node, tree } = await parseCommandNode("sed 's/x/y/' a.txt b.txt");
95
- try {
96
- expect(collectCommandTokens(node)).toEqual(["a.txt", "b.txt"]);
97
- } finally {
98
- tree.delete();
99
- }
100
- });
101
-
102
- it("sed -e: skips the explicit script arg-consuming flag and collects positionals", async () => {
103
- const { node, tree } = await parseCommandNode("sed -e 's/x/y/' file.txt");
104
- try {
105
- // -e consumes the next argument (the script), so file.txt is the first positional
106
- // Since hasExplicitScript is set by -e, the positional is not skipped
107
- expect(collectCommandTokens(node)).toEqual(["file.txt"]);
108
- } finally {
109
- tree.delete();
110
- }
111
- });
112
-
113
- it("sed -f: treats the next argument as a file path (file-consuming flag)", async () => {
114
- const { node, tree } = await parseCommandNode(
115
- "sed -f /scripts/script.sed file.txt",
116
- );
117
- try {
118
- // -f consumes the next arg as a file path (extracted), and sets hasExplicitScript
119
- expect(collectCommandTokens(node)).toEqual([
120
- "/scripts/script.sed",
121
- "file.txt",
122
- ]);
123
- } finally {
124
- tree.delete();
125
- }
126
- });
127
-
128
- it("grep: skips the first positional (pattern) and collects file arguments", async () => {
129
- const { node, tree } = await parseCommandNode(
130
- "grep pattern /etc/hosts /etc/passwd",
131
- );
132
- try {
133
- expect(collectCommandTokens(node)).toEqual(["/etc/hosts", "/etc/passwd"]);
134
- } finally {
135
- tree.delete();
136
- }
137
- });
138
-
139
- it("grep -e: with explicit -e flag, all positionals are file arguments", async () => {
140
- const { node, tree } = await parseCommandNode("grep -e pattern /etc/hosts");
141
- try {
142
- expect(collectCommandTokens(node)).toEqual(["/etc/hosts"]);
143
- } finally {
144
- tree.delete();
145
- }
146
- });
147
-
148
- it("grep: end-of-flags (--) causes subsequent args to be treated as positionals", async () => {
149
- const { node, tree } = await parseCommandNode("grep -- pattern /etc/hosts");
150
- try {
151
- // After --, both 'pattern' (first positional) and '/etc/hosts' are positionals.
152
- // pattern is the pattern positional and is skipped; /etc/hosts is collected.
153
- expect(collectCommandTokens(node)).toEqual(["/etc/hosts"]);
154
- } finally {
155
- tree.delete();
156
- }
157
- });
158
-
159
- it("sd: skips the first two positionals (FIND and REPLACE_WITH) as patterns", async () => {
160
- const { node, tree } = await parseCommandNode(
161
- "sd find replace file.txt other.txt",
162
- );
163
- try {
164
- expect(collectCommandTokens(node)).toEqual(["file.txt", "other.txt"]);
165
- } finally {
166
- tree.delete();
167
- }
168
- });
169
-
170
- it("rg: skips the pattern positional and collects file/dir arguments", async () => {
171
- const { node, tree } = await parseCommandNode("rg pattern /etc/");
172
- try {
173
- expect(collectCommandTokens(node)).toEqual(["/etc/"]);
174
- } finally {
175
- tree.delete();
176
- }
177
- });
178
- });
179
-
180
- // ── collectCommandTokens — generic commands ───────────────────────────────────
181
-
182
- describe("collectCommandTokens — generic commands", () => {
183
- it("collects all argument tokens after the command name", async () => {
184
- const { node, tree } = await parseCommandNode("cat /etc/hosts /etc/passwd");
185
- try {
186
- expect(collectCommandTokens(node)).toEqual(["/etc/hosts", "/etc/passwd"]);
187
- } finally {
188
- tree.delete();
189
- }
190
- });
191
-
192
- it("skips variable assignment prefixes", async () => {
193
- const { node, tree } = await parseCommandNode("FOO=/bar cat /etc/hosts");
194
- try {
195
- expect(collectCommandTokens(node)).toEqual(["/etc/hosts"]);
196
- } finally {
197
- tree.delete();
198
- }
199
- });
200
-
201
- it("collects no tokens for a bare command with no arguments", async () => {
202
- const { node, tree } = await parseCommandNode("ls");
203
- try {
204
- expect(collectCommandTokens(node)).toEqual([]);
205
- } finally {
206
- tree.delete();
207
- }
208
- });
209
- });
210
-
211
- // ── collectRedirectTokens ─────────────────────────────────────────────────────
212
-
213
- describe("collectRedirectTokens", () => {
214
- it("collects the destination path from a stdout redirect", async () => {
215
- const { node, tree } = await parseRedirectNode(
216
- "cat /etc/hosts > /tmp/out.txt",
217
- );
218
- try {
219
- expect(collectRedirectTokens(node)).toEqual(["/tmp/out.txt"]);
220
- } finally {
221
- tree.delete();
222
- }
223
- });
224
-
225
- it("collects the destination path from an append redirect", async () => {
226
- const { node, tree } = await parseRedirectNode(
227
- "echo hello >> /tmp/log.txt",
228
- );
229
- try {
230
- expect(collectRedirectTokens(node)).toEqual(["/tmp/log.txt"]);
231
- } finally {
232
- tree.delete();
233
- }
234
- });
235
-
236
- it("collects the source path from a stdin redirect", async () => {
237
- const { node, tree } = await parseRedirectNode("cat < /etc/hosts");
238
- try {
239
- expect(collectRedirectTokens(node)).toEqual(["/etc/hosts"]);
240
- } finally {
241
- tree.delete();
242
- }
243
- });
244
- });
245
-
246
- // ── collectPathCandidateTokens ────────────────────────────────────────────────
247
-
248
- describe("collectPathCandidateTokens", () => {
249
- it("collects all argument tokens from a simple command via the program root", async () => {
250
- const parser = await getParser();
251
- const tree = parser.parse("cat /etc/hosts");
252
- try {
253
- if (!tree) throw new Error("parse returned null");
254
- expect(collectPathCandidateTokens(tree.rootNode)).toEqual(["/etc/hosts"]);
255
- } finally {
256
- tree?.delete();
257
- }
258
- });
259
-
260
- it("collects redirect destinations as well as command arguments", async () => {
261
- const parser = await getParser();
262
- const tree = parser.parse("cat /etc/hosts > /tmp/out.txt");
263
- try {
264
- if (!tree) throw new Error("parse returned null");
265
- expect(collectPathCandidateTokens(tree.rootNode)).toEqual([
266
- "/etc/hosts",
267
- "/tmp/out.txt",
268
- ]);
269
- } finally {
270
- tree?.delete();
271
- }
272
- });
273
-
274
- it("returns empty array for heredoc-only content (SKIP_SUBTREE_TYPES)", async () => {
275
- const parser = await getParser();
276
- const tree = parser.parse("cat <<EOF\nhello\nEOF");
277
- try {
278
- if (!tree) throw new Error("parse returned null");
279
- // heredoc_body is in SKIP_SUBTREE_TYPES — its text must not be collected
280
- const tokens = collectPathCandidateTokens(tree.rootNode);
281
- expect(tokens).not.toContain("hello");
282
- } finally {
283
- tree?.delete();
284
- }
285
- });
286
-
287
- it("recurses into command substitution to collect nested tokens", async () => {
288
- const parser = await getParser();
289
- const tree = parser.parse("cat $(echo /etc/hosts)");
290
- try {
291
- if (!tree) throw new Error("parse returned null");
292
- // The command_substitution is a non-command, non-redirect node — recurse
293
- const tokens = collectPathCandidateTokens(tree.rootNode);
294
- // /etc/hosts is inside the substitution, collected by recursion
295
- expect(tokens).toContain("/etc/hosts");
296
- } finally {
297
- tree?.delete();
298
- }
299
- });
300
- });