@gotgenes/pi-permission-system 12.0.0 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,15 @@ export interface ScopedPermissionResolver {
17
17
  input: unknown,
18
18
  agentName?: string,
19
19
  ): PermissionCheckResult;
20
+ /**
21
+ * Resolve the cross-cutting `path` surface against a caller-supplied set of
22
+ * equivalent policy values, applying the current session rules. Used by the
23
+ * bash path gate, which computes cd-aware policy values per token.
24
+ */
25
+ resolvePathPolicy(
26
+ values: readonly string[],
27
+ agentName?: string,
28
+ ): PermissionCheckResult;
20
29
  }
21
30
 
22
31
  /**
@@ -53,6 +62,21 @@ export class PermissionResolver implements ScopedPermissionResolver {
53
62
  );
54
63
  }
55
64
 
65
+ /**
66
+ * Resolve the `path` surface for precomputed policy values, composing the
67
+ * current session ruleset so callers never thread it by hand.
68
+ */
69
+ resolvePathPolicy(
70
+ values: readonly string[],
71
+ agentName?: string,
72
+ ): PermissionCheckResult {
73
+ return this.permissionManager.checkPathPolicy(
74
+ values,
75
+ agentName,
76
+ this.sessionRules.getRuleset(),
77
+ );
78
+ }
79
+
56
80
  checkPermission(
57
81
  surface: string,
58
82
  input: unknown,
@@ -150,10 +150,8 @@ export class PermissionSession implements ToolCallGateInputs {
150
150
  return this.knownAgentName;
151
151
  }
152
152
 
153
- // Read by config-modal (`controller.session.lastKnownActiveAgentName`).
154
- // fallow cannot trace the getter through the command's object-literal
155
- // wiring, so it reports a false positive here.
156
- // fallow-ignore-next-line unused-class-member
153
+ // Read by the `index.ts` config-modal adapter closure:
154
+ // `permissionManager.getComposedConfigRules(session.lastKnownActiveAgentName ?? undefined)`.
157
155
  get lastKnownActiveAgentName(): string | null {
158
156
  return this.knownAgentName;
159
157
  }
package/src/rule.ts CHANGED
@@ -55,17 +55,8 @@ export function evaluate(
55
55
  defaultAction?: PermissionState,
56
56
  platform: NodeJS.Platform = process.platform,
57
57
  ): Rule {
58
- // On Windows, path-surface values are canonicalized + lowercased; fold the
59
- // pattern→value match (case and separators) so mixed-case / forward-slash
60
- // overrides still match. The surface→surface match stays exact.
61
- const matchOptions =
62
- platform === "win32" && PATH_SURFACES.has(surface)
63
- ? { caseInsensitive: true, windowsSeparators: true }
64
- : undefined;
65
- const rule = rules.findLast(
66
- (r) =>
67
- wildcardMatch(r.surface, surface) &&
68
- wildcardMatch(r.pattern, pattern, matchOptions),
58
+ const rule = rules.findLast((r) =>
59
+ ruleMatches(r, surface, pattern, platform),
69
60
  );
70
61
  if (rule !== undefined) return rule;
71
62
  return {
@@ -76,6 +67,33 @@ export function evaluate(
76
67
  };
77
68
  }
78
69
 
70
+ /**
71
+ * On Windows, path-surface values are canonicalized + lowercased; fold the
72
+ * pattern→value match (case and separators) so mixed-case / forward-slash
73
+ * overrides still match. The surface→surface match stays exact.
74
+ */
75
+ function pathMatchOptions(
76
+ surface: string,
77
+ platform: NodeJS.Platform,
78
+ ): { caseInsensitive: true; windowsSeparators: true } | undefined {
79
+ return platform === "win32" && PATH_SURFACES.has(surface)
80
+ ? { caseInsensitive: true, windowsSeparators: true }
81
+ : undefined;
82
+ }
83
+
84
+ function ruleMatches(
85
+ rule: Rule,
86
+ surface: string,
87
+ value: string,
88
+ platform: NodeJS.Platform,
89
+ ): boolean {
90
+ const matchOptions = pathMatchOptions(surface, platform);
91
+ return (
92
+ wildcardMatch(rule.surface, surface) &&
93
+ wildcardMatch(rule.pattern, value, matchOptions)
94
+ );
95
+ }
96
+
79
97
  /**
80
98
  * Evaluate a surface against an ordered list of candidate values, stopping at
81
99
  * the first candidate that matches a non-default rule (last-match-wins within
@@ -134,3 +152,35 @@ export function evaluateFirst(
134
152
  value: fallbackValue,
135
153
  };
136
154
  }
155
+
156
+ /**
157
+ * Evaluate equivalent lookup values as aliases of the same path.
158
+ *
159
+ * Unlike `evaluateFirst()`, this preserves rule ordering across aliases: the
160
+ * last rule that matches any alias wins. This lets absolute allowlists and
161
+ * legacy relative rules coexist without a catch-all match on the first alias
162
+ * masking a later, more specific rule on another alias.
163
+ */
164
+ export function evaluateAnyValue(
165
+ surface: string,
166
+ values: string[],
167
+ rules: Ruleset,
168
+ platform: NodeJS.Platform = process.platform,
169
+ ): { rule: Rule; value: string } {
170
+ const fallbackValue = values[0] ?? "*";
171
+ const rule = rules.findLast((r) =>
172
+ values.some((value) => ruleMatches(r, surface, value, platform)),
173
+ );
174
+ if (rule !== undefined) {
175
+ return {
176
+ rule,
177
+ value:
178
+ values.find((value) => ruleMatches(rule, surface, value, platform)) ??
179
+ fallbackValue,
180
+ };
181
+ }
182
+ return {
183
+ rule: evaluate(surface, fallbackValue, rules),
184
+ value: fallbackValue,
185
+ };
186
+ }
@@ -18,10 +18,7 @@ vi.mock("node:fs", () => ({
18
18
  }));
19
19
 
20
20
  import { formatDenyReason } from "#src/denial-messages";
21
- import {
22
- extractExternalPathsFromBashCommand,
23
- extractTokensForPathRules,
24
- } from "#src/handlers/gates/bash-path-extractor";
21
+ import { extractExternalPathsFromBashCommand } from "#src/handlers/gates/bash-path-extractor";
25
22
  import { formatBashExternalDirectoryAskPrompt } from "#src/handlers/gates/external-directory-messages";
26
23
 
27
24
  afterEach(() => {
@@ -957,80 +954,3 @@ describe("bash external-directory denial messages (centralized)", () => {
957
954
  expect(result).not.toContain("Hard stop");
958
955
  });
959
956
  });
960
-
961
- describe("extractTokensForPathRules", () => {
962
- test("extracts dot-files: cat .env", async () => {
963
- const tokens = await extractTokensForPathRules("cat .env");
964
- expect(tokens).toContain(".env");
965
- });
966
-
967
- test("extracts relative dot-paths: git add src/.env", async () => {
968
- const tokens = await extractTokensForPathRules("git add src/.env");
969
- expect(tokens).toContain("src/.env");
970
- });
971
-
972
- test("extracts nothing from plain words: echo hello", async () => {
973
- const tokens = await extractTokensForPathRules("echo hello");
974
- expect(tokens).toHaveLength(0);
975
- });
976
-
977
- test("extracts ./src and skips flags: rm -rf ./src", async () => {
978
- const tokens = await extractTokensForPathRules("rm -rf ./src");
979
- expect(tokens).toContain("./src");
980
- expect(tokens).not.toContain("-rf");
981
- });
982
-
983
- test("extracts absolute paths: cat /etc/hosts", async () => {
984
- const tokens = await extractTokensForPathRules("cat /etc/hosts");
985
- expect(tokens).toContain("/etc/hosts");
986
- });
987
-
988
- test("skips URLs: curl https://example.com", async () => {
989
- const tokens = await extractTokensForPathRules("curl https://example.com");
990
- expect(tokens).not.toContain("https://example.com");
991
- });
992
-
993
- test("extracts slash-containing tokens: cat src/foo.ts", async () => {
994
- const tokens = await extractTokensForPathRules("cat src/foo.ts");
995
- expect(tokens).toContain("src/foo.ts");
996
- });
997
-
998
- test("skips heredoc content", async () => {
999
- const tokens = await extractTokensForPathRules("cat <<EOF\n.env\nEOF");
1000
- expect(tokens).not.toContain(".env");
1001
- });
1002
-
1003
- test("skips @scope/package patterns", async () => {
1004
- const tokens = await extractTokensForPathRules(
1005
- "npm install @scope/package",
1006
- );
1007
- expect(tokens).not.toContain("@scope/package");
1008
- });
1009
-
1010
- test("skips env assignments", async () => {
1011
- const tokens = await extractTokensForPathRules("FOO=/bar command");
1012
- expect(tokens).not.toContain("FOO=/bar");
1013
- });
1014
-
1015
- test("skips bare-slash tokens", async () => {
1016
- const tokens = await extractTokensForPathRules("ls /");
1017
- expect(tokens).not.toContain("/");
1018
- });
1019
-
1020
- test("extracts redirect targets: echo test > .env", async () => {
1021
- const tokens = await extractTokensForPathRules("echo test > .env");
1022
- expect(tokens).toContain(".env");
1023
- });
1024
-
1025
- test("extracts multiple path tokens: cp .env .env.backup", async () => {
1026
- const tokens = await extractTokensForPathRules("cp .env .env.backup");
1027
- expect(tokens).toContain(".env");
1028
- expect(tokens).toContain(".env.backup");
1029
- });
1030
-
1031
- test("deduplicates repeated tokens", async () => {
1032
- const tokens = await extractTokensForPathRules("cat .env && rm .env");
1033
- const envCount = tokens.filter((t) => t === ".env").length;
1034
- expect(envCount).toBe(1);
1035
- });
1036
- });
@@ -2,6 +2,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { expect, test, vi } from "vitest";
5
+ import { loadUnifiedConfig } from "#src/config-loader";
5
6
  import { registerPermissionSystemCommand } from "#src/config-modal";
6
7
  import type { CommandConfigStore } from "#src/config-store";
7
8
  import {
@@ -89,8 +90,7 @@ test("permission-system command completions expose top-level config actions", ()
89
90
  const controller = {
90
91
  config: configStore,
91
92
  configPath,
92
- permissionManager: { getComposedConfigRules: () => [] as Ruleset },
93
- session: { lastKnownActiveAgentName: null },
93
+ getActiveAgentConfigRules: () => [] as Ruleset,
94
94
  };
95
95
 
96
96
  let definition: {
@@ -146,7 +146,7 @@ test("permission-system command handlers manage config summary, persistence, and
146
146
  current: () => config,
147
147
  save: (next) => {
148
148
  const currentConfig = normalizePermissionSystemConfig(
149
- JSON.parse(readFileSync(configPath, "utf-8")) as unknown,
149
+ loadUnifiedConfig(configPath).config,
150
150
  );
151
151
  const normalized = normalizePermissionSystemConfig(next);
152
152
  writeFileSync(
@@ -155,7 +155,7 @@ test("permission-system command handlers manage config summary, persistence, and
155
155
  "utf-8",
156
156
  );
157
157
  config = normalizePermissionSystemConfig(
158
- JSON.parse(readFileSync(configPath, "utf-8")) as unknown,
158
+ loadUnifiedConfig(configPath).config,
159
159
  );
160
160
  expect(config).not.toEqual(currentConfig);
161
161
  },
@@ -163,8 +163,7 @@ test("permission-system command handlers manage config summary, persistence, and
163
163
  const controller = {
164
164
  config: configStore,
165
165
  configPath,
166
- permissionManager: { getComposedConfigRules: () => [] as Ruleset },
167
- session: { lastKnownActiveAgentName: null },
166
+ getActiveAgentConfigRules: () => [] as Ruleset,
168
167
  };
169
168
 
170
169
  let registeredName = "";
@@ -262,8 +261,7 @@ test("show output includes rule origins when getComposedRules is provided", asyn
262
261
  const controller = {
263
262
  config: { current: () => config, save: () => {} } as CommandConfigStore,
264
263
  configPath: "/fake/config.json",
265
- permissionManager: { getComposedConfigRules: () => composedRules },
266
- session: { lastKnownActiveAgentName: null },
264
+ getActiveAgentConfigRules: () => composedRules,
267
265
  };
268
266
 
269
267
  let definition: {
@@ -295,8 +293,7 @@ test("show output omits rule summary when getComposedRules is not provided", asy
295
293
  const controller = {
296
294
  config: { current: () => config, save: () => {} } as CommandConfigStore,
297
295
  configPath: "/fake/config.json",
298
- permissionManager: { getComposedConfigRules: () => [] as Ruleset },
299
- session: { lastKnownActiveAgentName: null },
296
+ getActiveAgentConfigRules: () => [] as Ruleset,
300
297
  };
301
298
 
302
299
  let definition: {
@@ -0,0 +1,90 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+
6
+ import { loadAndMergeConfigs } from "#src/config-loader";
7
+ import { normalizePermissionSystemConfig } from "#src/extension-config";
8
+
9
+ /**
10
+ * Full-pipeline seam tests: write a temp config.json → loadAndMergeConfigs →
11
+ * normalizePermissionSystemConfig → assert values survive end to end.
12
+ *
13
+ * These tests guard the seam between the two normalizers — the class of bug
14
+ * fixed in #332, where a field declared on PermissionSystemExtensionConfig was
15
+ * silently dropped by the UnifiedPermissionConfig intermediate.
16
+ */
17
+ describe("config pipeline seam", () => {
18
+ let tempDir: string;
19
+ let agentDir: string;
20
+ let cwd: string;
21
+ let extensionRoot: string;
22
+
23
+ beforeEach(() => {
24
+ tempDir = mkdtempSync(join(tmpdir(), "config-pipeline-test-"));
25
+ agentDir = join(tempDir, "agent");
26
+ cwd = join(tempDir, "project");
27
+ extensionRoot = join(tempDir, "ext");
28
+ });
29
+
30
+ afterEach(() => {
31
+ rmSync(tempDir, { recursive: true, force: true });
32
+ });
33
+
34
+ function writeGlobal(content: Record<string, unknown>): void {
35
+ const dir = join(agentDir, "extensions", "pi-permission-system");
36
+ mkdirSync(dir, { recursive: true });
37
+ writeFileSync(join(dir, "config.json"), JSON.stringify(content));
38
+ }
39
+
40
+ it("runtime knob and preview-length field both survive the full pipeline", () => {
41
+ writeGlobal({
42
+ debugLog: true,
43
+ toolInputPreviewMaxLength: 1000,
44
+ });
45
+
46
+ const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
47
+ const config = normalizePermissionSystemConfig(mergeResult.merged);
48
+
49
+ expect(config.debugLog).toBe(true);
50
+ expect(config.toolInputPreviewMaxLength).toBe(1000);
51
+ });
52
+
53
+ it("text summary length field survives the full pipeline", () => {
54
+ writeGlobal({
55
+ toolTextSummaryMaxLength: 250,
56
+ });
57
+
58
+ const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
59
+ const config = normalizePermissionSystemConfig(mergeResult.merged);
60
+
61
+ expect(config.toolTextSummaryMaxLength).toBe(250);
62
+ });
63
+
64
+ it("project config overrides global preview-length field end to end", () => {
65
+ writeGlobal({ toolInputPreviewMaxLength: 200 });
66
+ const projectDir = join(cwd, ".pi", "extensions", "pi-permission-system");
67
+ mkdirSync(projectDir, { recursive: true });
68
+ writeFileSync(
69
+ join(projectDir, "config.json"),
70
+ JSON.stringify({ toolInputPreviewMaxLength: 500 }),
71
+ );
72
+
73
+ const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
74
+ const config = normalizePermissionSystemConfig(mergeResult.merged);
75
+
76
+ expect(config.toolInputPreviewMaxLength).toBe(500);
77
+ });
78
+
79
+ it("defaults apply when config file is absent", () => {
80
+ // No config files written — agentDir and cwd directories don't exist.
81
+ const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
82
+ const config = normalizePermissionSystemConfig(mergeResult.merged);
83
+
84
+ expect(config.debugLog).toBe(false);
85
+ expect(config.permissionReviewLog).toBe(true);
86
+ expect(config.yoloMode).toBe(false);
87
+ expect(config.toolInputPreviewMaxLength).toBeUndefined();
88
+ expect(config.toolTextSummaryMaxLength).toBeUndefined();
89
+ });
90
+ });
@@ -103,26 +103,6 @@ describe("normalizePermissionSystemConfig", () => {
103
103
  expect(result.yoloMode).toBe(false);
104
104
  });
105
105
 
106
- it("coerces non-boolean values to their defaults", () => {
107
- const result = normalizePermissionSystemConfig({
108
- debugLog: "yes",
109
- permissionReviewLog: 1,
110
- yoloMode: null,
111
- });
112
- expect(result.debugLog).toBe(false);
113
- expect(result.permissionReviewLog).toBe(true);
114
- expect(result.yoloMode).toBe(false);
115
- });
116
-
117
- it("handles null/undefined input gracefully", () => {
118
- const result = normalizePermissionSystemConfig(null);
119
- expect(result).toEqual({
120
- debugLog: false,
121
- permissionReviewLog: true,
122
- yoloMode: false,
123
- });
124
- });
125
-
126
106
  it("includes toolInputPreviewMaxLength when a valid positive integer is provided", () => {
127
107
  const result = normalizePermissionSystemConfig({
128
108
  toolInputPreviewMaxLength: 400,
@@ -135,25 +115,6 @@ describe("normalizePermissionSystemConfig", () => {
135
115
  expect("toolInputPreviewMaxLength" in result).toBe(false);
136
116
  });
137
117
 
138
- it("omits toolInputPreviewMaxLength for invalid values", () => {
139
- expect(
140
- normalizePermissionSystemConfig({ toolInputPreviewMaxLength: 0 })
141
- .toolInputPreviewMaxLength,
142
- ).toBeUndefined();
143
- expect(
144
- normalizePermissionSystemConfig({ toolInputPreviewMaxLength: -1 })
145
- .toolInputPreviewMaxLength,
146
- ).toBeUndefined();
147
- expect(
148
- normalizePermissionSystemConfig({ toolInputPreviewMaxLength: 200.5 })
149
- .toolInputPreviewMaxLength,
150
- ).toBeUndefined();
151
- expect(
152
- normalizePermissionSystemConfig({ toolInputPreviewMaxLength: "200" })
153
- .toolInputPreviewMaxLength,
154
- ).toBeUndefined();
155
- });
156
-
157
118
  it("includes toolTextSummaryMaxLength when a valid positive integer is provided", () => {
158
119
  const result = normalizePermissionSystemConfig({
159
120
  toolTextSummaryMaxLength: 120,
@@ -165,23 +126,4 @@ describe("normalizePermissionSystemConfig", () => {
165
126
  const result = normalizePermissionSystemConfig({});
166
127
  expect("toolTextSummaryMaxLength" in result).toBe(false);
167
128
  });
168
-
169
- it("omits toolTextSummaryMaxLength for invalid values", () => {
170
- expect(
171
- normalizePermissionSystemConfig({ toolTextSummaryMaxLength: 0 })
172
- .toolTextSummaryMaxLength,
173
- ).toBeUndefined();
174
- expect(
175
- normalizePermissionSystemConfig({ toolTextSummaryMaxLength: -1 })
176
- .toolTextSummaryMaxLength,
177
- ).toBeUndefined();
178
- expect(
179
- normalizePermissionSystemConfig({ toolTextSummaryMaxLength: 80.1 })
180
- .toolTextSummaryMaxLength,
181
- ).toBeUndefined();
182
- expect(
183
- normalizePermissionSystemConfig({ toolTextSummaryMaxLength: true })
184
- .toolTextSummaryMaxLength,
185
- ).toBeUndefined();
186
- });
187
129
  });
@@ -215,6 +215,49 @@ describe("describeBashPathGate", () => {
215
215
  expect(desc.preCheck?.state).toBe("deny");
216
216
  expect(desc.decision.value).toBe(".env");
217
217
  });
218
+
219
+ it("resolves cd-aware policy values while keeping the raw prompt token", async () => {
220
+ const resolver = makeResolver(
221
+ makeCheckResult({ state: "deny", matchedPattern: "*" }),
222
+ );
223
+ const result = (await describeGate(
224
+ makeTcc({
225
+ input: { command: "cd nested && cat src/file.txt" },
226
+ cwd: "/test/project",
227
+ }),
228
+ resolver,
229
+ )) as GateDescriptor;
230
+
231
+ expect(resolver.resolvePathPolicy).toHaveBeenCalledWith(
232
+ [
233
+ "/test/project/nested/src/file.txt",
234
+ "nested/src/file.txt",
235
+ "src/file.txt",
236
+ ],
237
+ undefined,
238
+ );
239
+ // The raw token drives the prompt, denial context, and session approval.
240
+ expect(result.denialContext).toMatchObject({ pathValue: "src/file.txt" });
241
+ expect(result.decision.value).toBe("src/file.txt");
242
+ });
243
+
244
+ it("does not resolve relative policy values through an unknown cd", async () => {
245
+ const resolver = makeResolver(
246
+ makeCheckResult({ state: "deny", matchedPattern: "*" }),
247
+ );
248
+ await describeGate(
249
+ makeTcc({
250
+ input: { command: 'cd "$DIR" && cat src/foo.ts' },
251
+ cwd: "/test/project",
252
+ }),
253
+ resolver,
254
+ );
255
+
256
+ expect(resolver.resolvePathPolicy).toHaveBeenCalledWith(
257
+ ["src/foo.ts"],
258
+ undefined,
259
+ );
260
+ });
218
261
  });
219
262
 
220
263
  // Home-relative path characterization (#350) ──────────────────────────────
@@ -229,7 +272,7 @@ describe("describeBashPathGate — home-relative paths", () => {
229
272
  // cat ~/.ssh/config → token "~/.ssh/config" extracted.
230
273
  const resolver = makePathDispatchResolver(
231
274
  {
232
- "~/.ssh/config": makeCheckResult({
275
+ "/mock/home/.ssh/config": makeCheckResult({
233
276
  state: "deny",
234
277
  matchedPattern: "~/.ssh/*",
235
278
  }),
@@ -253,7 +296,7 @@ describe("describeBashPathGate — home-relative paths", () => {
253
296
  it("extracts $HOME/... token and builds descriptor on deny", async () => {
254
297
  const resolver = makePathDispatchResolver(
255
298
  {
256
- "$HOME/.ssh/config": makeCheckResult({
299
+ "/mock/home/.ssh/config": makeCheckResult({
257
300
  state: "deny",
258
301
  matchedPattern: "$HOME/.ssh/*",
259
302
  }),
@@ -13,20 +13,50 @@ vi.mock("node:fs", () => ({
13
13
  import { BashProgram } from "#src/handlers/gates/bash-program";
14
14
 
15
15
  describe("BashProgram", () => {
16
- describe("pathTokens", () => {
17
- it("returns dot-files and relative path tokens", async () => {
18
- const program = await BashProgram.parse("cat .env src/foo.ts");
19
- expect(program.pathTokens()).toEqual([".env", "src/foo.ts"]);
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
+ ]);
20
34
  });
21
35
 
22
- it("returns an empty array when there are no path tokens", async () => {
23
- const program = await BashProgram.parse("echo hello");
24
- expect(program.pathTokens()).toEqual([]);
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
+ });
25
49
  });
26
50
 
27
- it("deduplicates repeated tokens across a command chain", async () => {
28
- const program = await BashProgram.parse("cat .env && rm .env");
29
- expect(program.pathTokens()).toEqual([".env"]);
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
+ });
30
60
  });
31
61
  });
32
62
 
@@ -322,7 +352,10 @@ describe("BashProgram", () => {
322
352
 
323
353
  it("derives both slices from a single parse", async () => {
324
354
  const program = await BashProgram.parse("cat .env /etc/hosts");
325
- expect(program.pathTokens()).toEqual([".env", "/etc/hosts"]);
355
+ expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
356
+ ".env",
357
+ "/etc/hosts",
358
+ ]);
326
359
  const external = program.externalPaths("/projects/my-app");
327
360
  expect(external).toContain("/etc/hosts");
328
361
  expect(external).not.toContain(".env");
@@ -23,7 +23,7 @@ vi.mock("#src/handlers/gates/bash-program", () => ({
23
23
  function makeMockBashProgram() {
24
24
  return {
25
25
  commands: vi.fn<() => []>(() => []),
26
- pathTokens: vi.fn<() => []>(() => []),
26
+ pathRuleCandidates: vi.fn<() => []>(() => []),
27
27
  externalPaths: vi.fn<() => []>(() => []),
28
28
  };
29
29
  }