@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.
- package/CHANGELOG.md +23 -0
- package/docs/assets/logo.png +0 -0
- package/docs/assets/logo.svg +213 -0
- package/docs/configuration.md +700 -0
- package/docs/cross-extension-api.md +525 -0
- package/docs/guides/permission-frontmatter-for-subagent-extensions.md +201 -0
- package/docs/guides/upstream-issue-template.md +113 -0
- package/docs/migration/legacy-to-flat.md +365 -0
- package/docs/opencode-compatibility.md +213 -0
- package/docs/session-approvals.md +68 -0
- package/docs/subagent-integration.md +102 -0
- package/docs/troubleshooting.md +53 -0
- package/package.json +5 -2
- package/src/access-intent/access-path.ts +29 -4
- package/src/access-intent/bash/bash-path-resolver.ts +39 -18
- package/src/access-intent/bash/msys-bash-tokens.ts +64 -0
- package/src/path-normalizer.ts +105 -2
- package/test/access-intent/access-path.test.ts +0 -277
- package/test/access-intent/bash/node-text.test.ts +0 -148
- package/test/access-intent/bash/parser.test.ts +0 -19
- package/test/access-intent/bash/program.test.ts +0 -673
- package/test/access-intent/bash/token-classification.test.ts +0 -363
- package/test/access-intent/bash/token-collection.test.ts +0 -300
- package/test/active-agent.test.ts +0 -155
- package/test/async-cache.test.ts +0 -48
- package/test/bash-arity.test.ts +0 -144
- package/test/bash-external-directory.test.ts +0 -1022
- package/test/builtin-tool-input-formatters.test.ts +0 -109
- package/test/canonicalize-path.test.ts +0 -119
- package/test/composition-root.test.ts +0 -698
- package/test/config-loader.test.ts +0 -740
- package/test/config-modal.test.ts +0 -320
- package/test/config-paths.test.ts +0 -83
- package/test/config-pipeline.test.ts +0 -90
- package/test/config-reporter.test.ts +0 -147
- package/test/config-store.test.ts +0 -466
- package/test/decision-audit.test.ts +0 -72
- package/test/decision-reporter.test.ts +0 -112
- package/test/denial-messages.test.ts +0 -714
- package/test/detect-permissive-bash-fallback.test.ts +0 -56
- package/test/expand-home.test.ts +0 -93
- package/test/extension-config.test.ts +0 -129
- package/test/extension-paths.test.ts +0 -108
- package/test/forwarded-permissions/io.test.ts +0 -251
- package/test/forwarding-manager.test.ts +0 -200
- package/test/handlers/before-agent-start.test.ts +0 -314
- package/test/handlers/external-directory-integration.test.ts +0 -515
- package/test/handlers/external-directory-session-dedup.test.ts +0 -175
- package/test/handlers/external-directory-symlink-acceptance.test.ts +0 -167
- package/test/handlers/gates/bash-command-metamorphic.test.ts +0 -88
- package/test/handlers/gates/bash-command.test.ts +0 -257
- package/test/handlers/gates/bash-external-directory.test.ts +0 -268
- package/test/handlers/gates/bash-path.test.ts +0 -346
- package/test/handlers/gates/candidate-check.test.ts +0 -52
- package/test/handlers/gates/external-directory-messages.test.ts +0 -85
- package/test/handlers/gates/external-directory-policy.test.ts +0 -134
- package/test/handlers/gates/external-directory.test.ts +0 -267
- package/test/handlers/gates/helpers.test.ts +0 -165
- package/test/handlers/gates/path.test.ts +0 -369
- package/test/handlers/gates/runner.test.ts +0 -408
- package/test/handlers/gates/skill-input-gate-pipeline.test.ts +0 -176
- package/test/handlers/gates/skill-input.test.ts +0 -128
- package/test/handlers/gates/skill-read.test.ts +0 -161
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +0 -356
- package/test/handlers/gates/tool.test.ts +0 -244
- package/test/handlers/input-events.test.ts +0 -168
- package/test/handlers/input.test.ts +0 -199
- package/test/handlers/lifecycle.test.ts +0 -221
- package/test/handlers/tool-call-boundary.test.ts +0 -145
- package/test/handlers/tool-call-events.test.ts +0 -277
- package/test/handlers/tool-call.test.ts +0 -395
- package/test/handlers/validate-requested-tool.test.ts +0 -92
- package/test/helpers/external-directory-fixtures.ts +0 -269
- package/test/helpers/gate-fixtures.ts +0 -316
- package/test/helpers/handler-fixtures.ts +0 -335
- package/test/helpers/make-fake-pi.ts +0 -100
- package/test/helpers/manager-harness.ts +0 -112
- package/test/helpers/session-fixtures.ts +0 -199
- package/test/input-normalizer.test.ts +0 -325
- package/test/logging.test.ts +0 -51
- package/test/mcp-targets.test.ts +0 -233
- package/test/node-modules-discovery.test.ts +0 -97
- package/test/normalize.test.ts +0 -247
- package/test/path-containment.test.ts +0 -161
- package/test/path-normalization.test.ts +0 -233
- package/test/path-normalizer.test.ts +0 -196
- package/test/path-surfaces.test.ts +0 -55
- package/test/pattern-suggest.test.ts +0 -248
- package/test/permission-dialog.test.ts +0 -205
- package/test/permission-event-rpc.test.ts +0 -560
- package/test/permission-events.test.ts +0 -400
- package/test/permission-forwarder.test.ts +0 -370
- package/test/permission-forwarding.test.ts +0 -315
- package/test/permission-gate.test.ts +0 -269
- package/test/permission-manager-unified.test.ts +0 -3714
- package/test/permission-merge.test.ts +0 -61
- package/test/permission-prompter.test.ts +0 -518
- package/test/permission-prompts.test.ts +0 -363
- package/test/permission-resolver.test.ts +0 -277
- package/test/permission-session.test.ts +0 -404
- package/test/permission-ui-prompt.test.ts +0 -146
- package/test/permissions-service.test.ts +0 -192
- package/test/pi-infrastructure-read.test.ts +0 -432
- package/test/policy-loader.test.ts +0 -561
- package/test/prompting-gateway.test.ts +0 -231
- package/test/rule.test.ts +0 -650
- package/test/safe-system-paths.test.ts +0 -46
- package/test/scope-merge.test.ts +0 -116
- package/test/service-lifecycle.test.ts +0 -163
- package/test/service.test.ts +0 -261
- package/test/session-approval.test.ts +0 -75
- package/test/session-logger.test.ts +0 -200
- package/test/session-rules.test.ts +0 -321
- package/test/session-start.test.ts +0 -112
- package/test/skill-prompt-sanitizer.test.ts +0 -418
- package/test/status.test.ts +0 -10
- package/test/subagent-context.test.ts +0 -372
- package/test/subagent-lifecycle-events.test.ts +0 -132
- package/test/subagent-registry.test.ts +0 -145
- package/test/synthesize.test.ts +0 -302
- package/test/system-prompt-sanitizer.test.ts +0 -382
- package/test/tool-access-extractor-registry.test.ts +0 -77
- package/test/tool-input-formatter-registry.test.ts +0 -75
- package/test/tool-input-path.test.ts +0 -84
- package/test/tool-input-preview.test.ts +0 -129
- package/test/tool-input-prompt-formatters.test.ts +0 -115
- package/test/tool-preview-formatter.test.ts +0 -458
- package/test/tool-registry.test.ts +0 -197
- package/test/value-guards.test.ts +0 -193
- package/test/wildcard-matcher.test.ts +0 -424
- package/test/yaml-frontmatter.test.ts +0 -91
- 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
|
-
});
|