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