@gotgenes/pi-permission-system 16.2.0 → 17.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +3 -2
  3. package/package.json +1 -1
  4. package/src/access-intent/access-intent.ts +8 -10
  5. package/src/access-intent/access-path.ts +39 -16
  6. package/src/access-intent/bash/command-enumeration.ts +79 -2
  7. package/src/access-intent/bash/cwd-projection.ts +16 -14
  8. package/src/access-intent/bash/parser.ts +2 -0
  9. package/src/access-intent/bash/program.ts +3 -0
  10. package/src/builtin-tool-input-formatters.ts +1 -1
  11. package/src/config-loader.ts +7 -7
  12. package/src/forwarded-permissions/permission-forwarder.ts +1 -1
  13. package/src/handlers/gates/bash-command.ts +15 -1
  14. package/src/handlers/gates/bash-external-directory.ts +1 -1
  15. package/src/handlers/gates/bash-path.ts +14 -14
  16. package/src/handlers/gates/external-directory.ts +3 -4
  17. package/src/handlers/gates/path.ts +11 -7
  18. package/src/handlers/gates/skill-read.ts +1 -1
  19. package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -1
  20. package/src/handlers/permission-gate-handler.ts +1 -2
  21. package/src/handlers/tool-call-boundary.ts +1 -2
  22. package/src/input-normalizer.ts +1 -1
  23. package/src/mcp-targets.ts +1 -1
  24. package/src/normalize.ts +1 -1
  25. package/src/path-utils.ts +1 -1
  26. package/src/permission-manager.ts +1 -1
  27. package/src/permission-prompts.ts +1 -1
  28. package/src/policy-loader.ts +2 -2
  29. package/src/tool-input-prompt-formatters.ts +1 -1
  30. package/src/tool-preview-formatter.ts +1 -1
  31. package/src/tool-registry.ts +1 -1
  32. package/src/{common.ts → value-guards.ts} +0 -66
  33. package/src/yaml-frontmatter.ts +65 -0
  34. package/test/access-intent/access-path.test.ts +83 -23
  35. package/test/access-intent/bash/node-text.test.ts +1 -0
  36. package/test/access-intent/bash/program.test.ts +116 -17
  37. package/test/handlers/external-directory-integration.test.ts +40 -153
  38. package/test/handlers/external-directory-session-dedup.test.ts +38 -262
  39. package/test/handlers/gates/bash-command.test.ts +63 -0
  40. package/test/handlers/gates/bash-external-directory.test.ts +1 -2
  41. package/test/handlers/gates/bash-path.test.ts +9 -9
  42. package/test/handlers/gates/external-directory-policy.test.ts +10 -18
  43. package/test/handlers/gates/path.test.ts +43 -12
  44. package/test/helpers/external-directory-fixtures.ts +269 -0
  45. package/test/helpers/gate-fixtures.ts +1 -2
  46. package/test/permission-resolver.test.ts +5 -32
  47. package/test/{common.test.ts → value-guards.test.ts} +2 -96
  48. package/test/yaml-frontmatter.test.ts +91 -0
@@ -2,8 +2,6 @@ import type {
2
2
  ExtensionContext,
3
3
  InputEventResult,
4
4
  } from "@earendil-works/pi-coding-agent";
5
-
6
- import { toRecord } from "#src/common";
7
5
  import {
8
6
  formatMissingToolNameReason,
9
7
  formatUnknownToolReason,
@@ -14,6 +12,7 @@ import {
14
12
  getToolNameFromValue,
15
13
  type ToolRegistry,
16
14
  } from "#src/tool-registry";
15
+ import { toRecord } from "#src/value-guards";
17
16
  import type { GateRunner } from "./gates/runner";
18
17
  import type {
19
18
  GateNotifier,
@@ -1,8 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
-
3
- import { toRecord } from "#src/common";
4
2
  import type { DecisionRecorder } from "#src/decision-audit";
5
3
  import type { DecisionReporter } from "#src/decision-reporter";
4
+ import { toRecord } from "#src/value-guards";
6
5
  import type { GateOutcome } from "./gates/types";
7
6
 
8
7
  /** The SDK-facing result shape for a `tool_call` handler. */
@@ -1,7 +1,7 @@
1
1
  import { stripBashCommentLines } from "./bash-arity";
2
- import { getNonEmptyString, toRecord } from "./common";
3
2
  import { createMcpPermissionTargets } from "./mcp-targets";
4
3
  import { getPathPolicyValues, PATH_BEARING_TOOLS } from "./path-utils";
4
+ import { getNonEmptyString, toRecord } from "./value-guards";
5
5
 
6
6
  /**
7
7
  * Construct a surface-appropriate input object from a raw value string.
@@ -1,4 +1,4 @@
1
- import { getNonEmptyString, toRecord } from "./common";
1
+ import { getNonEmptyString, toRecord } from "./value-guards";
2
2
 
3
3
  /**
4
4
  * An ordered accumulator that owns the uniqueness invariant.
package/src/normalize.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { isDenyWithReason, isPermissionState } from "./common";
2
1
  import type { Rule, Ruleset } from "./rule";
3
2
  import type { FlatPermissionConfig } from "./types";
3
+ import { isDenyWithReason, isPermissionState } from "./value-guards";
4
4
 
5
5
  /**
6
6
  * Convert a flat permission config into a Ruleset.
package/src/path-utils.ts CHANGED
@@ -8,9 +8,9 @@ import {
8
8
  } from "node:path";
9
9
 
10
10
  import { canonicalizePath } from "./canonicalize-path";
11
- import { getNonEmptyString, toRecord } from "./common";
12
11
  import { expandHomePath } from "./expand-home";
13
12
  import type { ToolAccessExtractorLookup } from "./tool-access-extractor-registry";
13
+ import { getNonEmptyString, toRecord } from "./value-guards";
14
14
  import { wildcardMatch } from "./wildcard-matcher";
15
15
 
16
16
  export function normalizePathForComparison(
@@ -1,6 +1,5 @@
1
1
  import { join } from "node:path";
2
2
  import type { ResolvedAccessIntent } from "./access-intent/access-intent";
3
- import { isPermissionState } from "./common";
4
3
  import {
5
4
  getGlobalConfigPath,
6
5
  getProjectAgentsDir,
@@ -28,6 +27,7 @@ import type {
28
27
  PermissionCheckResult,
29
28
  PermissionState,
30
29
  } from "./types";
30
+ import { isPermissionState } from "./value-guards";
31
31
 
32
32
  const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
33
33
  "bash",
@@ -1,8 +1,8 @@
1
- import { getNonEmptyString, toRecord } from "./common";
2
1
  import { matchQualifier } from "./denial-messages";
3
2
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
4
3
  import type { ToolPreviewFormatter } from "./tool-preview-formatter";
5
4
  import type { PermissionCheckResult } from "./types";
5
+ import { getNonEmptyString, toRecord } from "./value-guards";
6
6
 
7
7
  // NOTE: formatDenyReason, formatUserDeniedReason, and
8
8
  // formatPermissionHardStopHint have been moved to denial-messages.ts.
@@ -1,8 +1,6 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
-
5
- import { extractFrontmatter, parseSimpleYamlMap, toRecord } from "./common";
6
4
  import {
7
5
  loadUnifiedConfig,
8
6
  normalizeUnifiedConfig,
@@ -10,6 +8,8 @@ import {
10
8
  } from "./config-loader";
11
9
  import { getGlobalConfigPath } from "./config-paths";
12
10
  import type { ScopeConfig } from "./types";
11
+ import { toRecord } from "./value-guards";
12
+ import { extractFrontmatter, parseSimpleYamlMap } from "./yaml-frontmatter";
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
15
  // File-stamp helper
@@ -1,5 +1,5 @@
1
- import { getNonEmptyString, toRecord } from "./common";
2
1
  import { countTextLines, formatCount } from "./tool-input-preview";
2
+ import { getNonEmptyString, toRecord } from "./value-guards";
3
3
 
4
4
  export function getPromptPath(input: Record<string, unknown>): string | null {
5
5
  return getNonEmptyString(input.path) ?? getNonEmptyString(input.file_path);
@@ -1,4 +1,3 @@
1
- import { getNonEmptyString, toRecord } from "./common";
2
1
  import type { PermissionSystemExtensionConfig } from "./extension-config";
3
2
  import type { ToolInputFormatterLookup } from "./tool-input-formatter-registry";
4
3
  import {
@@ -15,6 +14,7 @@ import {
15
14
  getPromptPath,
16
15
  } from "./tool-input-prompt-formatters";
17
16
  import type { PermissionCheckResult } from "./types";
17
+ import { getNonEmptyString, toRecord } from "./value-guards";
18
18
 
19
19
  export interface ToolPreviewFormatterOptions {
20
20
  toolInputPreviewMaxLength: number;
@@ -1,4 +1,4 @@
1
- import { getNonEmptyString, toRecord } from "./common";
1
+ import { getNonEmptyString, toRecord } from "./value-guards";
2
2
 
3
3
  /** Narrow interface for the Pi tool API subset used by handler classes. */
4
4
  export interface ToolRegistry {
@@ -53,69 +53,3 @@ export function isDenyWithReason(value: unknown): value is DenyWithReason {
53
53
  (record.reason === undefined || typeof record.reason === "string")
54
54
  );
55
55
  }
56
-
57
- type StackNode = { indent: number; target: Record<string, unknown> };
58
-
59
- export function parseSimpleYamlMap(input: string): Record<string, unknown> {
60
- const root: Record<string, unknown> = {};
61
- const stack: StackNode[] = [{ indent: -1, target: root }];
62
-
63
- const lines = input.split(/\r?\n/);
64
- for (const rawLine of lines) {
65
- if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) {
66
- continue;
67
- }
68
-
69
- const indent = rawLine.length - rawLine.trimStart().length;
70
- const line = rawLine.trim();
71
- const separatorIndex = line.indexOf(":");
72
- if (separatorIndex <= 0) {
73
- continue;
74
- }
75
-
76
- const key = line
77
- .slice(0, separatorIndex)
78
- .trim()
79
- .replace(/^['"]|['"]$/g, "");
80
- const rawValue = line.slice(separatorIndex + 1).trim();
81
-
82
- while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
83
- stack.pop();
84
- }
85
-
86
- const current = stack[stack.length - 1].target;
87
-
88
- if (!rawValue) {
89
- const child: Record<string, unknown> = {};
90
- current[key] = child;
91
- stack.push({ indent, target: child });
92
- continue;
93
- }
94
-
95
- let scalar = rawValue;
96
- if (
97
- (scalar.startsWith('"') && scalar.endsWith('"')) ||
98
- (scalar.startsWith("'") && scalar.endsWith("'"))
99
- ) {
100
- scalar = scalar.slice(1, -1);
101
- }
102
-
103
- current[key] = scalar;
104
- }
105
-
106
- return root;
107
- }
108
-
109
- export function extractFrontmatter(markdown: string): string {
110
- const normalized = markdown.replace(/\r\n/g, "\n");
111
- if (!normalized.startsWith("---\n")) {
112
- return "";
113
- }
114
-
115
- const end = normalized.indexOf("\n---", 4);
116
- if (end === -1) {
117
- return "";
118
- }
119
-
120
- return normalized.slice(4, end);
121
- }
@@ -0,0 +1,65 @@
1
+ type StackNode = { indent: number; target: Record<string, unknown> };
2
+
3
+ export function parseSimpleYamlMap(input: string): Record<string, unknown> {
4
+ const root: Record<string, unknown> = {};
5
+ const stack: StackNode[] = [{ indent: -1, target: root }];
6
+
7
+ const lines = input.split(/\r?\n/);
8
+ for (const rawLine of lines) {
9
+ if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) {
10
+ continue;
11
+ }
12
+
13
+ const indent = rawLine.length - rawLine.trimStart().length;
14
+ const line = rawLine.trim();
15
+ const separatorIndex = line.indexOf(":");
16
+ if (separatorIndex <= 0) {
17
+ continue;
18
+ }
19
+
20
+ const key = line
21
+ .slice(0, separatorIndex)
22
+ .trim()
23
+ .replace(/^['"]|['"]$/g, "");
24
+ const rawValue = line.slice(separatorIndex + 1).trim();
25
+
26
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
27
+ stack.pop();
28
+ }
29
+
30
+ const current = stack[stack.length - 1].target;
31
+
32
+ if (!rawValue) {
33
+ const child: Record<string, unknown> = {};
34
+ current[key] = child;
35
+ stack.push({ indent, target: child });
36
+ continue;
37
+ }
38
+
39
+ let scalar = rawValue;
40
+ if (
41
+ (scalar.startsWith('"') && scalar.endsWith('"')) ||
42
+ (scalar.startsWith("'") && scalar.endsWith("'"))
43
+ ) {
44
+ scalar = scalar.slice(1, -1);
45
+ }
46
+
47
+ current[key] = scalar;
48
+ }
49
+
50
+ return root;
51
+ }
52
+
53
+ export function extractFrontmatter(markdown: string): string {
54
+ const normalized = markdown.replace(/\r\n/g, "\n");
55
+ if (!normalized.startsWith("---\n")) {
56
+ return "";
57
+ }
58
+
59
+ const end = normalized.indexOf("\n---", 4);
60
+ if (end === -1) {
61
+ return "";
62
+ }
63
+
64
+ return normalized.slice(4, end);
65
+ }
@@ -21,7 +21,7 @@ vi.mock("node:fs", () => ({
21
21
 
22
22
  import { AccessPath } from "#src/access-intent/access-path";
23
23
 
24
- describe("AccessPath.forExternalDirectory", () => {
24
+ describe("AccessPath.forPath", () => {
25
25
  const cwd = "/projects/my-app";
26
26
 
27
27
  beforeEach(() => {
@@ -35,30 +35,59 @@ describe("AccessPath.forExternalDirectory", () => {
35
35
  realpathSync.mockImplementation((p: string) =>
36
36
  p.startsWith("/tmp") ? `/private${p}` : p,
37
37
  );
38
- expect(
39
- AccessPath.forExternalDirectory("/tmp/x", cwd).matchValues(),
40
- ).toEqual(["/tmp/x", "/private/tmp/x"]);
38
+ expect(AccessPath.forPath("/tmp/x", { cwd }).matchValues()).toEqual([
39
+ "/tmp/x",
40
+ "/private/tmp/x",
41
+ ]);
41
42
  });
42
43
 
43
44
  test("deduplicates when the canonical form equals the lexical form", () => {
44
- expect(
45
- AccessPath.forExternalDirectory("/etc/hosts", cwd).matchValues(),
46
- ).toEqual(["/etc/hosts"]);
45
+ expect(AccessPath.forPath("/etc/hosts", { cwd }).matchValues()).toEqual([
46
+ "/etc/hosts",
47
+ ]);
47
48
  });
48
49
 
49
50
  test("keeps the relative aliases for an in-cwd token without duplicating", () => {
50
- expect(
51
- AccessPath.forExternalDirectory("src/foo.ts", cwd).matchValues(),
52
- ).toEqual(["/projects/my-app/src/foo.ts", "src/foo.ts"]);
51
+ expect(AccessPath.forPath("src/foo.ts", { cwd }).matchValues()).toEqual([
52
+ "/projects/my-app/src/foo.ts",
53
+ "src/foo.ts",
54
+ ]);
53
55
  });
54
56
 
55
57
  test("includes only the lexical aliases when canonical is empty", () => {
56
58
  // Force canonicalizePath to return the original (no-op symlink resolution
57
59
  // effectively means canonical === lexical, handled by dedup).
58
60
  expect(
59
- AccessPath.forExternalDirectory("/etc/hosts", cwd).matchValues(),
61
+ AccessPath.forPath("/etc/hosts", { cwd }).matchValues(),
60
62
  ).not.toHaveLength(0);
61
63
  });
64
+
65
+ test("resolves a relative token against an explicit resolveBase", () => {
66
+ // The cd-folded effective base differs from cwd (the bash-path case).
67
+ expect(
68
+ AccessPath.forPath("foo.ts", {
69
+ cwd,
70
+ resolveBase: "/projects/my-app/sub",
71
+ }).matchValues(),
72
+ ).toEqual(["/projects/my-app/sub/foo.ts", "sub/foo.ts", "foo.ts"]);
73
+ });
74
+
75
+ test("adds the canonical alias resolved against resolveBase", () => {
76
+ realpathSync.mockImplementation((p: string) =>
77
+ p === "/projects/my-app/sub/foo.ts" ? "/real/foo.ts" : p,
78
+ );
79
+ expect(
80
+ AccessPath.forPath("foo.ts", {
81
+ cwd,
82
+ resolveBase: "/projects/my-app/sub",
83
+ }).matchValues(),
84
+ ).toEqual([
85
+ "/projects/my-app/sub/foo.ts",
86
+ "sub/foo.ts",
87
+ "foo.ts",
88
+ "/real/foo.ts",
89
+ ]);
90
+ });
62
91
  });
63
92
 
64
93
  describe("boundaryValue()", () => {
@@ -66,19 +95,19 @@ describe("AccessPath.forExternalDirectory", () => {
66
95
  realpathSync.mockImplementation((p: string) =>
67
96
  p.startsWith("/tmp") ? `/private${p}` : p,
68
97
  );
69
- expect(
70
- AccessPath.forExternalDirectory("/tmp/x", cwd).boundaryValue(),
71
- ).toBe("/private/tmp/x");
98
+ expect(AccessPath.forPath("/tmp/x", { cwd }).boundaryValue()).toBe(
99
+ "/private/tmp/x",
100
+ );
72
101
  });
73
102
 
74
103
  test("returns the lexical form when path has no symlinks", () => {
75
- expect(
76
- AccessPath.forExternalDirectory("/etc/hosts", cwd).boundaryValue(),
77
- ).toBe("/etc/hosts");
104
+ expect(AccessPath.forPath("/etc/hosts", { cwd }).boundaryValue()).toBe(
105
+ "/etc/hosts",
106
+ );
78
107
  });
79
108
 
80
109
  test("returns empty string for empty input", () => {
81
- expect(AccessPath.forExternalDirectory("", cwd).boundaryValue()).toBe("");
110
+ expect(AccessPath.forPath("", { cwd }).boundaryValue()).toBe("");
82
111
  });
83
112
  });
84
113
 
@@ -88,20 +117,51 @@ describe("AccessPath.forExternalDirectory", () => {
88
117
  p.startsWith("/tmp") ? `/private${p}` : p,
89
118
  );
90
119
  // Even when the path resolves to a different canonical, value() stays lexical.
91
- expect(AccessPath.forExternalDirectory("/tmp/x", cwd).value()).toBe(
92
- "/tmp/x",
93
- );
120
+ expect(AccessPath.forPath("/tmp/x", { cwd }).value()).toBe("/tmp/x");
94
121
  });
95
122
 
96
123
  test("normalizes the path against cwd", () => {
97
124
  // A relative path becomes an absolute lexical value.
98
- expect(AccessPath.forExternalDirectory("src/foo.ts", cwd).value()).toBe(
125
+ expect(AccessPath.forPath("src/foo.ts", { cwd }).value()).toBe(
99
126
  "/projects/my-app/src/foo.ts",
100
127
  );
101
128
  });
102
129
 
130
+ test("normalizes a relative path against an explicit resolveBase", () => {
131
+ expect(
132
+ AccessPath.forPath("foo.ts", {
133
+ cwd,
134
+ resolveBase: "/projects/my-app/sub",
135
+ }).value(),
136
+ ).toBe("/projects/my-app/sub/foo.ts");
137
+ });
138
+
103
139
  test("returns empty string for empty input", () => {
104
- expect(AccessPath.forExternalDirectory("", cwd).value()).toBe("");
140
+ expect(AccessPath.forPath("", { cwd }).value()).toBe("");
105
141
  });
106
142
  });
107
143
  });
144
+
145
+ describe("AccessPath.forLiteral", () => {
146
+ beforeEach(() => {
147
+ realpathSync.mockReset();
148
+ realpathSync.mockImplementation((p: string) => p);
149
+ });
150
+
151
+ test("matchValues() carries only the literal — no canonical, no absolute", () => {
152
+ expect(AccessPath.forLiteral("foo.ts").matchValues()).toEqual(["foo.ts"]);
153
+ });
154
+
155
+ test("boundaryValue() is empty (no outside-cwd notion for an unknown base)", () => {
156
+ expect(AccessPath.forLiteral("foo.ts").boundaryValue()).toBe("");
157
+ });
158
+
159
+ test("value() returns the literal", () => {
160
+ expect(AccessPath.forLiteral("foo.ts").value()).toBe("foo.ts");
161
+ });
162
+
163
+ test("an empty literal yields no match values", () => {
164
+ expect(AccessPath.forLiteral("").matchValues()).toEqual([]);
165
+ expect(AccessPath.forLiteral("").value()).toBe("");
166
+ });
167
+ });
@@ -10,6 +10,7 @@ function makeNode(type: string, text: string, children: TSNode[] = []): TSNode {
10
10
  return {
11
11
  type,
12
12
  text,
13
+ startIndex: 0,
13
14
  childCount: children.length,
14
15
  isNamed: true,
15
16
  child: (i) => children[i] ?? null,
@@ -16,14 +16,20 @@ describe("BashProgram", () => {
16
16
  describe("pathRuleCandidates", () => {
17
17
  const cwd = "/projects/my-app";
18
18
 
19
+ beforeEach(() => {
20
+ realpathSync.mockReset();
21
+ realpathSync.mockImplementation((p: string) => p);
22
+ });
23
+
19
24
  it("adds absolute and relative policy values for relative tokens", async () => {
20
25
  const program = await BashProgram.parse("cat src/foo.ts", cwd);
21
- expect(program.pathRuleCandidates()).toEqual([
22
- {
23
- token: "src/foo.ts",
24
- policyValues: ["/projects/my-app/src/foo.ts", "src/foo.ts"],
25
- },
26
+ const candidates = program.pathRuleCandidates();
27
+ expect(candidates.map(({ token }) => token)).toEqual(["src/foo.ts"]);
28
+ expect(candidates[0].path.matchValues()).toEqual([
29
+ "/projects/my-app/src/foo.ts",
30
+ "src/foo.ts",
26
31
  ]);
32
+ expect(candidates[0].path.value()).toBe("/projects/my-app/src/foo.ts");
27
33
  });
28
34
 
29
35
  it("resolves tokens after literal cd against the effective directory", async () => {
@@ -34,14 +40,28 @@ describe("BashProgram", () => {
34
40
  const fileCandidate = program
35
41
  .pathRuleCandidates()
36
42
  .find((candidate) => candidate.token === "src/file.txt");
37
- expect(fileCandidate).toEqual({
38
- token: "src/file.txt",
39
- policyValues: [
40
- "/projects/my-app/nested/src/file.txt",
41
- "nested/src/file.txt",
42
- "src/file.txt",
43
- ],
44
- });
43
+ expect(fileCandidate?.path.matchValues()).toEqual([
44
+ "/projects/my-app/nested/src/file.txt",
45
+ "nested/src/file.txt",
46
+ "src/file.txt",
47
+ ]);
48
+ expect(fileCandidate?.path.value()).toBe(
49
+ "/projects/my-app/nested/src/file.txt",
50
+ );
51
+ });
52
+
53
+ it("adds the canonical alias for a symlinked token (#486)", async () => {
54
+ // /projects/my-app/src/foo.ts is a symlink to /vault/foo.ts.
55
+ realpathSync.mockImplementation((p: string) =>
56
+ p === "/projects/my-app/src/foo.ts" ? "/vault/foo.ts" : p,
57
+ );
58
+ const program = await BashProgram.parse("cat src/foo.ts", cwd);
59
+ const candidate = program.pathRuleCandidates()[0];
60
+ expect(candidate.path.matchValues()).toEqual([
61
+ "/projects/my-app/src/foo.ts",
62
+ "src/foo.ts",
63
+ "/vault/foo.ts",
64
+ ]);
45
65
  });
46
66
 
47
67
  it("does not absolute-allow relative tokens after unknown cd", async () => {
@@ -52,10 +72,22 @@ describe("BashProgram", () => {
52
72
  const fileCandidate = program
53
73
  .pathRuleCandidates()
54
74
  .find((candidate) => candidate.token === "src/foo.ts");
55
- expect(fileCandidate).toEqual({
56
- token: "src/foo.ts",
57
- policyValues: ["src/foo.ts"],
58
- });
75
+ expect(fileCandidate?.path.matchValues()).toEqual(["src/foo.ts"]);
76
+ expect(fileCandidate?.path.value()).toBe("src/foo.ts");
77
+ });
78
+
79
+ it("keeps an unknown-cd token literal-only even when it would resolve a symlink (#393)", async () => {
80
+ // A canonical alias here would resolve against the wrong (unknown) base.
81
+ realpathSync.mockImplementation(() => "/somewhere/else");
82
+ const program = await BashProgram.parse(
83
+ 'cd "$DIR" && cat src/foo.ts',
84
+ cwd,
85
+ );
86
+ const fileCandidate = program
87
+ .pathRuleCandidates()
88
+ .find((candidate) => candidate.token === "src/foo.ts");
89
+ expect(fileCandidate?.path.matchValues()).toEqual(["src/foo.ts"]);
90
+ expect(fileCandidate?.path.boundaryValue()).toBe("");
59
91
  });
60
92
  });
61
93
 
@@ -435,6 +467,73 @@ describe("BashProgram", () => {
435
467
  expect((await BashProgram.parse("", cwd)).commands()).toEqual([]);
436
468
  expect((await BashProgram.parse(" ", cwd)).commands()).toEqual([]);
437
469
  });
470
+
471
+ it("strips a leading env-var assignment prefix", async () => {
472
+ const program = await BashProgram.parse(
473
+ "AWS_PROFILE=prod aws ec2 terminate-instances --instance-ids i-1",
474
+ cwd,
475
+ );
476
+ expect(program.commands()).toEqual([
477
+ { text: "aws ec2 terminate-instances --instance-ids i-1" },
478
+ ]);
479
+ });
480
+
481
+ it("strips multiple leading env-var assignments", async () => {
482
+ const program = await BashProgram.parse("A=1 B=2 aws s3 ls", cwd);
483
+ expect(program.commands()).toEqual([{ text: "aws s3 ls" }]);
484
+ });
485
+
486
+ it("strips the env-var prefix of each command in a chain", async () => {
487
+ const program = await BashProgram.parse(
488
+ "X=1 aws sts get-caller-identity && ls",
489
+ cwd,
490
+ );
491
+ expect(program.commands()).toEqual([
492
+ { text: "aws sts get-caller-identity" },
493
+ { text: "ls" },
494
+ ]);
495
+ });
496
+
497
+ it("keeps a pure assignment with no command unchanged", async () => {
498
+ const program = await BashProgram.parse("FOO=bar", cwd);
499
+ expect(program.commands()).toEqual([{ text: "FOO=bar" }]);
500
+ });
501
+
502
+ describe("opaque-payload wrappers", () => {
503
+ it.each([
504
+ ['bash -c "rm -rf /"', 'bash -c "rm -rf /"'],
505
+ ['sh -c "rm -rf /"', 'sh -c "rm -rf /"'],
506
+ ['dash -c "rm -rf /"', 'dash -c "rm -rf /"'],
507
+ ['zsh -c "rm -rf /"', 'zsh -c "rm -rf /"'],
508
+ ['ksh -c "rm -rf /"', 'ksh -c "rm -rf /"'],
509
+ ['eval "rm -rf /"', 'eval "rm -rf /"'],
510
+ ['/bin/bash -c "rm -rf /"', '/bin/bash -c "rm -rf /"'],
511
+ ['bash -ec "rm -rf /"', 'bash -ec "rm -rf /"'],
512
+ ])("flags %s as opaque", async (command, text) => {
513
+ const program = await BashProgram.parse(command, cwd);
514
+ expect(program.commands()).toEqual([{ text, opaque: true }]);
515
+ });
516
+
517
+ it("flags an env-prefixed wrapper as opaque after stripping the prefix", async () => {
518
+ const program = await BashProgram.parse(
519
+ 'AWS_PROFILE=prod bash -c "rm -rf /"',
520
+ cwd,
521
+ );
522
+ expect(program.commands()).toEqual([
523
+ { text: 'bash -c "rm -rf /"', opaque: true },
524
+ ]);
525
+ });
526
+
527
+ it.each([
528
+ "bash script.sh",
529
+ "bash",
530
+ "ls -la",
531
+ "grep -c foo file",
532
+ ])("does not flag %s as opaque", async (command) => {
533
+ const program = await BashProgram.parse(command, cwd);
534
+ expect(program.commands()).toEqual([{ text: command }]);
535
+ });
536
+ });
438
537
  });
439
538
 
440
539
  it("derives both slices from a single parse", async () => {