@gotgenes/pi-permission-system 16.0.2 → 16.1.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [16.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.0.2...pi-permission-system-v16.1.0) (2026-06-26)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** introduce AccessPath value object ([c00d5c5](https://github.com/gotgenes/pi-packages/commit/c00d5c580a828234fafd7946be4e81313665d25d)), closes [#476](https://github.com/gotgenes/pi-packages/issues/476)
14
+
8
15
  ## [16.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.0.1...pi-permission-system-v16.0.2) (2026-06-25)
9
16
 
10
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "16.0.2",
3
+ "version": "16.1.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,88 @@
1
+ import {
2
+ canonicalNormalizePathForComparison,
3
+ getPathPolicyValues,
4
+ normalizePathForComparison,
5
+ } from "#src/path-utils";
6
+
7
+ /**
8
+ * A path's two representations held behind type-distinct accessors.
9
+ *
10
+ * A single `string` carrying both meanings was the root cause of [#418]:
11
+ * both external-directory gates matched config patterns against the
12
+ * symlink-resolved (canonical) path instead of the typed (lexical) path,
13
+ * defeating a configured `/tmp/*` allow.
14
+ *
15
+ * `AccessPath` makes the misuse a compile error:
16
+ * - {@link matchValues} returns `string[]` — the lexical alias union ∪ canonical,
17
+ * for `external_directory` pattern matching.
18
+ * - {@link boundaryValue} returns `string` — the canonical form, for
19
+ * outside-CWD containment and infra-read checks.
20
+ * - {@link value} returns `string` — the lexical absolute form, for display,
21
+ * approval patterns, decision values, and logs.
22
+ *
23
+ * Construct via {@link forExternalDirectory}; the constructor is private.
24
+ */
25
+ export class AccessPath {
26
+ private constructor(
27
+ private readonly lexical: string,
28
+ private readonly matchAliases: readonly string[],
29
+ private readonly canonical: string,
30
+ ) {}
31
+
32
+ /**
33
+ * Pattern-match values for the `external_directory` surface: the lexical
34
+ * alias union plus the canonical alias, so a config pattern on either the
35
+ * typed form (`/tmp/*`) or the symlink-resolved form (`/private/tmp/*`)
36
+ * matches (#418).
37
+ *
38
+ * Collapses to the lexical aliases when the canonical equals one of them
39
+ * (e.g. when the path is not a symlink).
40
+ */
41
+ matchValues(): string[] {
42
+ return this.canonical
43
+ ? [...new Set([...this.matchAliases, this.canonical])]
44
+ : [...this.matchAliases];
45
+ }
46
+
47
+ /**
48
+ * Canonical (symlink-resolved, win32-lowercased) form, for the outside-CWD
49
+ * boundary decision and Pi infrastructure-read containment checks.
50
+ *
51
+ * Returns `""` when the path could not be resolved (empty input).
52
+ */
53
+ boundaryValue(): string {
54
+ return this.canonical;
55
+ }
56
+
57
+ /**
58
+ * Lexical (as-typed, normalized but not symlink-resolved) form, for display,
59
+ * approval patterns, decision values, and log messages.
60
+ *
61
+ * Returns `""` for empty input.
62
+ */
63
+ value(): string {
64
+ return this.lexical;
65
+ }
66
+
67
+ /**
68
+ * Build an `AccessPath` for a tool-input or bash-token path resolved against
69
+ * `cwd`.
70
+ *
71
+ * - `matchValues()` returns the same set as the former
72
+ * `getExternalDirectoryPolicyValues(pathValue, cwd)` — the lexical alias
73
+ * union from `getPathPolicyValues` plus the canonical alias from
74
+ * `canonicalNormalizePathForComparison` (#418).
75
+ * - `boundaryValue()` returns `canonicalNormalizePathForComparison(pathValue, cwd)`,
76
+ * which is win32-lowercased (#382) — do not substitute a raw
77
+ * `canonicalizePath` output here.
78
+ * - `value()` returns `normalizePathForComparison(pathValue, cwd)`, the
79
+ * absolute lexical form.
80
+ */
81
+ static forExternalDirectory(pathValue: string, cwd: string): AccessPath {
82
+ return new AccessPath(
83
+ normalizePathForComparison(pathValue, cwd),
84
+ getPathPolicyValues(pathValue, { cwd }),
85
+ canonicalNormalizePathForComparison(pathValue, cwd),
86
+ );
87
+ }
88
+ }
@@ -1,4 +1,5 @@
1
1
  import { isAbsolute, join, resolve } from "node:path";
2
+ import { AccessPath } from "#src/access-intent/access-path";
2
3
  import {
3
4
  ARG_NODE_TYPES,
4
5
  SKIP_SUBTREE_TYPES,
@@ -409,11 +410,11 @@ function getPolicyValuesForRuleCandidate(
409
410
  export function projectExternalPaths(
410
411
  candidates: readonly PathCandidate[],
411
412
  cwd: string,
412
- ): string[] {
413
+ ): AccessPath[] {
413
414
  const normalizedCwd = canonicalizePath(normalizePathForComparison(cwd, cwd));
414
415
 
415
416
  const seen = new Set<string>();
416
- const externalPaths: string[] = [];
417
+ const externalPaths: AccessPath[] = [];
417
418
 
418
419
  for (const { token, base } of candidates) {
419
420
  const candidate = classifyTokenAsPathCandidate(token);
@@ -432,7 +433,9 @@ export function projectExternalPaths(
432
433
  !seen.has(canonical)
433
434
  ) {
434
435
  seen.add(canonical);
435
- externalPaths.push(lexical);
436
+ // The factory recomputes the canonical via canonicalNormalizePathForComparison
437
+ // (win32-lowercased, #382) rather than reusing the raw canonicalizePath output.
438
+ externalPaths.push(AccessPath.forExternalDirectory(lexical, cwd));
436
439
  }
437
440
  continue;
438
441
  }
@@ -452,7 +455,9 @@ export function projectExternalPaths(
452
455
  !seen.has(canonical)
453
456
  ) {
454
457
  seen.add(canonical);
455
- externalPaths.push(lexical);
458
+ // The factory recomputes the canonical via canonicalNormalizePathForComparison
459
+ // (win32-lowercased, #382) rather than reusing the raw canonicalizePath output.
460
+ externalPaths.push(AccessPath.forExternalDirectory(lexical, cwd));
456
461
  }
457
462
  }
458
463
 
@@ -1,3 +1,4 @@
1
+ import type { AccessPath } from "#src/access-intent/access-path";
1
2
  import {
2
3
  type BashCommand,
3
4
  collectCommands,
@@ -25,7 +26,7 @@ export type { BashCommand, BashPathRuleCandidate };
25
26
  export class BashProgram {
26
27
  private constructor(
27
28
  private readonly commandUnits: readonly BashCommand[],
28
- private readonly resolvedExternalPaths: readonly string[],
29
+ private readonly resolvedExternalPaths: readonly AccessPath[],
29
30
  private readonly resolvedRuleCandidates: readonly BashPathRuleCandidate[],
30
31
  ) {}
31
32
 
@@ -73,16 +74,15 @@ export class BashProgram {
73
74
  }
74
75
 
75
76
  /**
76
- * Deduplicated paths that resolve outside `cwd`, in their lexical (as-typed,
77
- * normalized but not symlink-resolved) form.
77
+ * Deduplicated paths that resolve outside `cwd`, as {@link AccessPath} value
78
+ * objects holding both the lexical (as-typed) and canonical (symlink-resolved)
79
+ * forms behind distinct accessors.
78
80
  *
79
81
  * Resolved eagerly at parse time against the `cwd` supplied to `parse()`.
80
- * The outside-`cwd` decision and the dedup identity use the canonical
81
- * (symlink-resolved) form, but the returned value is the lexical form so
82
- * `external_directory` config patterns match the path as the user typed it
83
- * (#418); the gate re-derives the canonical alias for matching.
82
+ * Use `.matchValues()` for `external_directory` pattern matching and
83
+ * `.boundaryValue()` for containment checks; `.value()` for display and logs.
84
84
  */
85
- externalPaths(): string[] {
85
+ externalPaths(): AccessPath[] {
86
86
  return [...this.resolvedExternalPaths];
87
87
  }
88
88
 
@@ -1,13 +1,11 @@
1
1
  import type { BashProgram } from "#src/access-intent/bash/program";
2
2
  import { getNonEmptyString, toRecord } from "#src/common";
3
- import { getExternalDirectoryPolicyValues } from "#src/path-utils";
4
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
5
4
  import { SessionApproval } from "#src/session-approval";
6
5
  import { deriveApprovalPattern } from "#src/session-rules";
7
- import type { PermissionCheckResult } from "#src/types";
8
- import { pickMostRestrictive } from "./candidate-check";
9
6
  import type { GateResult } from "./descriptor";
10
7
  import { formatBashExternalDirectoryAskPrompt } from "./external-directory-messages";
8
+ import { selectUncoveredExternalPaths } from "./external-directory-policy";
11
9
  import type { ToolCallContext } from "./types";
12
10
 
13
11
  /**
@@ -34,27 +32,17 @@ export function describeBashExternalDirectoryGate(
34
32
  const externalPaths = bashProgram.externalPaths();
35
33
  if (externalPaths.length === 0) return null;
36
34
 
37
- // Collect paths whose resolved state is not already "allow".
38
- // Checking state (not source) ensures config-level allow rules (source: "special")
39
- // suppress the prompt just as session-level allow rules (source: "session") do.
40
- const uncoveredEntries: Array<{
41
- path: string;
42
- check: PermissionCheckResult;
43
- }> = [];
44
- for (const p of externalPaths) {
45
- // Match each path against both its typed and symlink-resolved aliases on
46
- // the external_directory surface, so a config pattern on either form
47
- // applies (#418).
48
- const check = resolver.resolvePathPolicy(
49
- getExternalDirectoryPolicyValues(p, tcc.cwd),
35
+ // Resolve every external path on the external_directory surface and keep the
36
+ // ones not already allowed (config-level allows suppress the prompt just as
37
+ // session-level allows do); the shared helper single-sources the #418 alias
38
+ // matching and the worst-uncovered selection.
39
+ const { uncovered: uncoveredEntries, worstCheck } =
40
+ selectUncoveredExternalPaths(
41
+ externalPaths,
42
+ resolver,
50
43
  tcc.agentName ?? undefined,
51
- "external_directory",
52
44
  );
53
- if (check.state !== "allow") {
54
- uncoveredEntries.push({ path: p, check });
55
- }
56
- }
57
- const uncoveredPaths = uncoveredEntries.map(({ path }) => path);
45
+ const uncoveredPaths = uncoveredEntries.map(({ path }) => path.value());
58
46
 
59
47
  if (uncoveredPaths.length === 0) {
60
48
  return {
@@ -67,19 +55,17 @@ export function describeBashExternalDirectoryGate(
67
55
  toolName: tcc.toolName,
68
56
  agentName: tcc.agentName,
69
57
  command,
70
- externalPaths,
58
+ externalPaths: externalPaths.map((p) => p.value()),
71
59
  resolution: "session_approved",
72
60
  },
73
61
  },
74
62
  };
75
63
  }
76
64
 
77
- // Use the most restrictive check among uncovered paths as the pre-check result.
78
- // This ensures a config-level "deny" rule is not downgraded to "ask" by the
79
- // generic "*" catch-all that the old path-less checkPermission call returned.
80
- const worstCheck =
81
- pickMostRestrictive(uncoveredEntries.map(({ check }) => check)) ??
82
- uncoveredEntries[0].check;
65
+ // After the early bypass, at least one path is uncovered, so worstCheck is
66
+ // defined; the fallback keeps TypeScript happy across the early return. A
67
+ // config-level "deny" is preserved (not downgraded to the catch-all "ask").
68
+ const preCheck = worstCheck ?? uncoveredEntries[0].check;
83
69
 
84
70
  const bashExtMessage = formatBashExternalDirectoryAskPrompt(
85
71
  command,
@@ -122,6 +108,6 @@ export function describeBashExternalDirectoryGate(
122
108
  surface: "external_directory",
123
109
  value: command,
124
110
  },
125
- preCheck: worstCheck,
111
+ preCheck,
126
112
  };
127
113
  }
@@ -4,12 +4,18 @@ import { BashProgram } from "#src/access-intent/bash/program";
4
4
  * Extract paths from a bash command string that resolve outside CWD.
5
5
  *
6
6
  * Thin facade over {@link BashProgram.externalPaths}; parses the command and
7
- * returns the cd-aware external paths. See `BashProgram` for the parsing and
8
- * resolution semantics.
7
+ * returns the cd-aware external paths in their lexical (as-typed) string form.
8
+ * See `BashProgram` for the parsing and resolution semantics.
9
+ *
10
+ * Returns `string[]` (not `AccessPath[]`) so the large projection-correctness
11
+ * test suite in `bash-external-directory.test.ts` can assert path values
12
+ * without migrating every call site.
9
13
  */
10
14
  export async function extractExternalPathsFromBashCommand(
11
15
  command: string,
12
16
  cwd: string,
13
17
  ): Promise<string[]> {
14
- return (await BashProgram.parse(command, cwd)).externalPaths();
18
+ return (await BashProgram.parse(command, cwd))
19
+ .externalPaths()
20
+ .map((p) => p.value());
15
21
  }
@@ -0,0 +1,65 @@
1
+ import type { AccessPath } from "#src/access-intent/access-path";
2
+ import type { ScopedPermissionResolver } from "#src/permission-resolver";
3
+ import type { PermissionCheckResult } from "#src/types";
4
+ import { pickMostRestrictive } from "./candidate-check";
5
+
6
+ /** An external path whose resolved `external_directory` state is not "allow". */
7
+ export interface UncoveredExternalPath {
8
+ path: AccessPath;
9
+ check: PermissionCheckResult;
10
+ }
11
+
12
+ /** The uncovered external paths plus the most restrictive check among them. */
13
+ export interface UncoveredExternalPaths {
14
+ uncovered: UncoveredExternalPath[];
15
+ /** Worst check among uncovered paths; `undefined` only when none are uncovered. */
16
+ worstCheck: PermissionCheckResult | undefined;
17
+ }
18
+
19
+ /**
20
+ * Resolve one external path's policy on the `external_directory` surface.
21
+ *
22
+ * Matches against the typed and symlink-resolved aliases
23
+ * ({@link AccessPath.matchValues}) so a config pattern on either form applies
24
+ * (#418). This is the single source for the alias-derivation plus
25
+ * surface-tagged resolve that the two external-directory gates previously
26
+ * duplicated.
27
+ */
28
+ export function resolveExternalDirectoryPolicy(
29
+ path: AccessPath,
30
+ resolver: ScopedPermissionResolver,
31
+ agentName: string | undefined,
32
+ ): PermissionCheckResult {
33
+ return resolver.resolvePathPolicy(
34
+ path.matchValues(),
35
+ agentName,
36
+ "external_directory",
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Resolve a set of external paths and select those not already allowed.
42
+ *
43
+ * Each path is resolved via {@link resolveExternalDirectoryPolicy}; entries
44
+ * whose state is not "allow" are collected (filtering on state, not source, so
45
+ * config-level allow rules suppress the prompt just as session-level allow
46
+ * rules do), and the most restrictive uncovered check is returned so a config
47
+ * "deny" is not downgraded to the catch-all "ask".
48
+ */
49
+ export function selectUncoveredExternalPaths(
50
+ paths: readonly AccessPath[],
51
+ resolver: ScopedPermissionResolver,
52
+ agentName: string | undefined,
53
+ ): UncoveredExternalPaths {
54
+ const uncovered: UncoveredExternalPath[] = [];
55
+ for (const path of paths) {
56
+ const check = resolveExternalDirectoryPolicy(path, resolver, agentName);
57
+ if (check.state !== "allow") {
58
+ uncovered.push({ path, check });
59
+ }
60
+ }
61
+ return {
62
+ uncovered,
63
+ worstCheck: pickMostRestrictive(uncovered.map(({ check }) => check)),
64
+ };
65
+ }
@@ -1,6 +1,5 @@
1
+ import { AccessPath } from "#src/access-intent/access-path";
1
2
  import {
2
- canonicalNormalizePathForComparison,
3
- getExternalDirectoryPolicyValues,
4
3
  getToolInputPath,
5
4
  isPathOutsideWorkingDirectory,
6
5
  isPiInfrastructureRead,
@@ -12,6 +11,7 @@ import { deriveApprovalPattern } from "#src/session-rules";
12
11
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
13
12
  import type { GateResult } from "./descriptor";
14
13
  import { formatExternalDirectoryAskPrompt } from "./external-directory-messages";
14
+ import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
15
15
  import type { ToolCallContext } from "./types";
16
16
 
17
17
  /**
@@ -42,10 +42,11 @@ export function describeExternalDirectoryGate(
42
42
  // The boundary decision (above) and the infrastructure-read containment
43
43
  // check (below) use the canonical, symlink-resolved path; pattern matching
44
44
  // uses the typed and resolved aliases (#418).
45
- const canonicalExtPath = canonicalNormalizePathForComparison(
45
+ const accessPath = AccessPath.forExternalDirectory(
46
46
  externalDirectoryPath,
47
47
  tcc.cwd,
48
48
  );
49
+ const canonicalExtPath = accessPath.boundaryValue();
49
50
 
50
51
  // ── Pi infrastructure read bypass ──────────────────────────────────────
51
52
  if (
@@ -83,13 +84,11 @@ export function describeExternalDirectoryGate(
83
84
  tcc.agentName ?? undefined,
84
85
  );
85
86
 
86
- // Match against both the typed and symlink-resolved aliases on the
87
- // external_directory surface, so a config pattern on either form applies
88
- // (#418). The runner consumes this preCheck and skips its own resolve.
89
- const preCheck = resolver.resolvePathPolicy(
90
- getExternalDirectoryPolicyValues(externalDirectoryPath, tcc.cwd),
87
+ // The runner consumes this preCheck and skips its own resolve.
88
+ const preCheck = resolveExternalDirectoryPolicy(
89
+ accessPath,
90
+ resolver,
91
91
  tcc.agentName ?? undefined,
92
- "external_directory",
93
92
  );
94
93
  const pattern = deriveApprovalPattern(
95
94
  normalizePathForComparison(externalDirectoryPath, tcc.cwd),
package/src/path-utils.ts CHANGED
@@ -112,26 +112,6 @@ export function getPathPolicyValues(
112
112
  ];
113
113
  }
114
114
 
115
- /**
116
- * Equivalent `external_directory` policy-match values for a path: the lexical
117
- * (as-typed) alias list plus the canonical (symlink-resolved) absolute path.
118
- *
119
- * The outside-CWD boundary decision uses the canonical form separately; this
120
- * helper exists only for pattern matching, so a user's pattern on the typed
121
- * path (`/tmp/*`) and on the resolved path (`/private/tmp/*`) both match under
122
- * the last-match-wins alias evaluation. On systems where the path is not a
123
- * symlink the canonical form equals the lexical absolute alias and the `Set`
124
- * collapses it, leaving today's behavior unchanged.
125
- */
126
- export function getExternalDirectoryPolicyValues(
127
- pathValue: string,
128
- cwd: string,
129
- ): string[] {
130
- const lexical = getPathPolicyValues(pathValue, { cwd });
131
- const canonical = canonicalNormalizePathForComparison(pathValue, cwd);
132
- return canonical ? [...new Set([...lexical, canonical])] : lexical;
133
- }
134
-
135
115
  function getAbsolutePathPolicyValues(
136
116
  pathValue: string,
137
117
  options: PathPolicyValueOptions,
@@ -0,0 +1,107 @@
1
+ import { beforeEach, describe, expect, test, vi } from "vitest";
2
+
3
+ // Mock node:os so tilde-expansion is deterministic across platforms.
4
+ vi.mock("node:os", () => {
5
+ const homedir = vi.fn(() => "/mock/home");
6
+ return {
7
+ homedir,
8
+ default: { homedir },
9
+ };
10
+ });
11
+
12
+ // Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
13
+ // Default implementation is identity — lexical tests are unaffected.
14
+ const realpathSync = vi.hoisted(() =>
15
+ vi.fn<(path: string) => string>((p) => p),
16
+ );
17
+ vi.mock("node:fs", () => ({
18
+ realpathSync,
19
+ default: { realpathSync },
20
+ }));
21
+
22
+ import { AccessPath } from "#src/access-intent/access-path";
23
+
24
+ describe("AccessPath.forExternalDirectory", () => {
25
+ const cwd = "/projects/my-app";
26
+
27
+ beforeEach(() => {
28
+ realpathSync.mockReset();
29
+ realpathSync.mockImplementation((p: string) => p);
30
+ });
31
+
32
+ describe("matchValues()", () => {
33
+ test("adds the symlink-resolved alias alongside the typed path", () => {
34
+ // /tmp -> /private/tmp (the macOS symlink from the bug report, #418).
35
+ realpathSync.mockImplementation((p: string) =>
36
+ p.startsWith("/tmp") ? `/private${p}` : p,
37
+ );
38
+ expect(
39
+ AccessPath.forExternalDirectory("/tmp/x", cwd).matchValues(),
40
+ ).toEqual(["/tmp/x", "/private/tmp/x"]);
41
+ });
42
+
43
+ test("deduplicates when the canonical form equals the lexical form", () => {
44
+ expect(
45
+ AccessPath.forExternalDirectory("/etc/hosts", cwd).matchValues(),
46
+ ).toEqual(["/etc/hosts"]);
47
+ });
48
+
49
+ 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"]);
53
+ });
54
+
55
+ test("includes only the lexical aliases when canonical is empty", () => {
56
+ // Force canonicalizePath to return the original (no-op symlink resolution
57
+ // effectively means canonical === lexical, handled by dedup).
58
+ expect(
59
+ AccessPath.forExternalDirectory("/etc/hosts", cwd).matchValues(),
60
+ ).not.toHaveLength(0);
61
+ });
62
+ });
63
+
64
+ describe("boundaryValue()", () => {
65
+ test("returns the canonical (symlink-resolved) form", () => {
66
+ realpathSync.mockImplementation((p: string) =>
67
+ p.startsWith("/tmp") ? `/private${p}` : p,
68
+ );
69
+ expect(
70
+ AccessPath.forExternalDirectory("/tmp/x", cwd).boundaryValue(),
71
+ ).toBe("/private/tmp/x");
72
+ });
73
+
74
+ test("returns the lexical form when path has no symlinks", () => {
75
+ expect(
76
+ AccessPath.forExternalDirectory("/etc/hosts", cwd).boundaryValue(),
77
+ ).toBe("/etc/hosts");
78
+ });
79
+
80
+ test("returns empty string for empty input", () => {
81
+ expect(AccessPath.forExternalDirectory("", cwd).boundaryValue()).toBe("");
82
+ });
83
+ });
84
+
85
+ describe("value()", () => {
86
+ test("returns the lexical (as-typed, normalized) form", () => {
87
+ realpathSync.mockImplementation((p: string) =>
88
+ p.startsWith("/tmp") ? `/private${p}` : p,
89
+ );
90
+ // 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
+ );
94
+ });
95
+
96
+ test("normalizes the path against cwd", () => {
97
+ // A relative path becomes an absolute lexical value.
98
+ expect(AccessPath.forExternalDirectory("src/foo.ts", cwd).value()).toBe(
99
+ "/projects/my-app/src/foo.ts",
100
+ );
101
+ });
102
+
103
+ test("returns empty string for empty input", () => {
104
+ expect(AccessPath.forExternalDirectory("", cwd).value()).toBe("");
105
+ });
106
+ });
107
+ });
@@ -70,7 +70,9 @@ describe("BashProgram", () => {
70
70
  it("returns absolute paths resolving outside cwd", async () => {
71
71
  const program = await BashProgram.parse("cat /etc/hosts", cwd);
72
72
  // Subset matcher: the path is normalized before comparison.
73
- expect(program.externalPaths()).toContain("/etc/hosts");
73
+ expect(program.externalPaths().map((p) => p.value())).toContain(
74
+ "/etc/hosts",
75
+ );
74
76
  });
75
77
 
76
78
  it("excludes paths within cwd", async () => {
@@ -95,7 +97,9 @@ describe("BashProgram", () => {
95
97
  "cd nested/deep && cd .. && cat ../../etc/passwd",
96
98
  cwd,
97
99
  );
98
- expect(program.externalPaths()).toContain("/projects/etc/passwd");
100
+ expect(program.externalPaths().map((p) => p.value())).toContain(
101
+ "/projects/etc/passwd",
102
+ );
99
103
  });
100
104
 
101
105
  it("folds a cd that is not the first command", async () => {
@@ -112,13 +116,17 @@ describe("BashProgram", () => {
112
116
  // `cd a &` runs in a subshell, so it must not update the running
113
117
  // directory; ../b resolves against cwd and escapes.
114
118
  const program = await BashProgram.parse("cd a & cat ../b", cwd);
115
- expect(program.externalPaths()).toContain("/projects/b");
119
+ expect(program.externalPaths().map((p) => p.value())).toContain(
120
+ "/projects/b",
121
+ );
116
122
  });
117
123
 
118
124
  it("does not fold a cd inside a pipeline", async () => {
119
125
  // Pipeline members run in subshells; the cd must not leak.
120
126
  const program = await BashProgram.parse("cd nested | cat ../b", cwd);
121
- expect(program.externalPaths()).toContain("/projects/b");
127
+ expect(program.externalPaths().map((p) => p.value())).toContain(
128
+ "/projects/b",
129
+ );
122
130
  });
123
131
 
124
132
  it("folds a cd inside a subshell for paths within that subshell", async () => {
@@ -130,7 +138,9 @@ describe("BashProgram", () => {
130
138
  it("does not leak a subshell cd to following commands", async () => {
131
139
  // The subshell cd resets on exit, so ../y resolves against cwd.
132
140
  const program = await BashProgram.parse("( cd sub ) && cat ../y", cwd);
133
- expect(program.externalPaths()).toContain("/projects/y");
141
+ expect(program.externalPaths().map((p) => p.value())).toContain(
142
+ "/projects/y",
143
+ );
134
144
  });
135
145
 
136
146
  it("persists a cd inside a brace group to later commands in the group", async () => {
@@ -152,14 +162,18 @@ describe("BashProgram", () => {
152
162
  "echo $(cd q && cat ../r)",
153
163
  cwd,
154
164
  );
155
- expect(program.externalPaths()).toContain("/projects/r");
165
+ expect(program.externalPaths().map((p) => p.value())).toContain(
166
+ "/projects/r",
167
+ );
156
168
  });
157
169
 
158
170
  it("flags relative paths conservatively after a non-literal cd", async () => {
159
171
  // cd "$DIR" makes the effective dir unknowable; ../x could be anywhere,
160
172
  // so it is flagged (least-privilege).
161
173
  const program = await BashProgram.parse('cd "$DIR" && cat ../x', cwd);
162
- expect(program.externalPaths()).toContain("/projects/x");
174
+ expect(program.externalPaths().map((p) => p.value())).toContain(
175
+ "/projects/x",
176
+ );
163
177
  });
164
178
 
165
179
  it("flags even a within-cwd relative path after a non-literal cd", async () => {
@@ -169,7 +183,7 @@ describe("BashProgram", () => {
169
183
  'cd "$DIR" && cat src/../within.txt',
170
184
  cwd,
171
185
  );
172
- expect(program.externalPaths()).toContain(
186
+ expect(program.externalPaths().map((p) => p.value())).toContain(
173
187
  "/projects/my-app/within.txt",
174
188
  );
175
189
  });
@@ -186,7 +200,9 @@ describe("BashProgram", () => {
186
200
 
187
201
  it("treats `cd -` as an unknown effective directory", async () => {
188
202
  const program = await BashProgram.parse("cd - && cat ../x", cwd);
189
- expect(program.externalPaths()).toContain("/projects/x");
203
+ expect(program.externalPaths().map((p) => p.value())).toContain(
204
+ "/projects/x",
205
+ );
190
206
  });
191
207
 
192
208
  it("recovers a known base when a later cd is absolute", async () => {
@@ -233,7 +249,9 @@ describe("BashProgram", () => {
233
249
  "cd a && cd b 2>&1 | tail ; cat ../../x",
234
250
  cwd,
235
251
  );
236
- expect(program.externalPaths()).toContain("/projects/x");
252
+ expect(program.externalPaths().map((p) => p.value())).toContain(
253
+ "/projects/x",
254
+ );
237
255
  });
238
256
 
239
257
  it("resolves a downstream pipe stage against the folded base", async () => {
@@ -262,7 +280,7 @@ describe("BashProgram", () => {
262
280
  "cat /projects/my-app/link/hosts",
263
281
  cwd,
264
282
  );
265
- const external = program.externalPaths();
283
+ const external = program.externalPaths().map((p) => p.value());
266
284
  expect(external).toContain("/projects/my-app/link/hosts");
267
285
  expect(external).not.toContain("/etc/hosts");
268
286
  });
@@ -426,7 +444,7 @@ describe("BashProgram", () => {
426
444
  ".env",
427
445
  "/etc/hosts",
428
446
  ]);
429
- const external = program.externalPaths();
447
+ const external = program.externalPaths().map((p) => p.value());
430
448
  expect(external).toContain("/etc/hosts");
431
449
  expect(external).not.toContain(".env");
432
450
  });
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { AccessPath } from "#src/access-intent/access-path";
3
+ import {
4
+ resolveExternalDirectoryPolicy,
5
+ selectUncoveredExternalPaths,
6
+ } from "#src/handlers/gates/external-directory-policy";
7
+ import type { PermissionCheckResult } from "#src/types";
8
+
9
+ import { makeResolver } from "#test/helpers/gate-fixtures";
10
+
11
+ const cwd = "/test/project";
12
+
13
+ function makeCheckResult(
14
+ state: "allow" | "deny" | "ask",
15
+ overrides: Partial<PermissionCheckResult> = {},
16
+ ): PermissionCheckResult {
17
+ return {
18
+ state,
19
+ toolName: "external_directory",
20
+ source: "special",
21
+ origin: "builtin",
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ describe("resolveExternalDirectoryPolicy", () => {
27
+ it("resolves the path's match aliases on the external_directory surface (#418)", () => {
28
+ const path = AccessPath.forExternalDirectory("/outside/a.ts", cwd);
29
+ const resolver = makeResolver(makeCheckResult("ask"));
30
+
31
+ const result = resolveExternalDirectoryPolicy(path, resolver, undefined);
32
+
33
+ expect(resolver.resolvePathPolicy).toHaveBeenCalledWith(
34
+ path.matchValues(),
35
+ undefined,
36
+ "external_directory",
37
+ );
38
+ expect(result).toEqual(makeCheckResult("ask"));
39
+ });
40
+
41
+ it("threads the agent name through to the resolver", () => {
42
+ const path = AccessPath.forExternalDirectory("/outside/a.ts", cwd);
43
+ const resolver = makeResolver(makeCheckResult("allow"));
44
+
45
+ resolveExternalDirectoryPolicy(path, resolver, "reviewer");
46
+
47
+ expect(resolver.resolvePathPolicy).toHaveBeenCalledWith(
48
+ path.matchValues(),
49
+ "reviewer",
50
+ "external_directory",
51
+ );
52
+ });
53
+ });
54
+
55
+ describe("selectUncoveredExternalPaths", () => {
56
+ it("returns no uncovered paths when every path resolves to allow", () => {
57
+ const paths = [
58
+ AccessPath.forExternalDirectory("/outside/a.ts", cwd),
59
+ AccessPath.forExternalDirectory("/outside/b.ts", cwd),
60
+ ];
61
+ const resolver = makeResolver(makeCheckResult("allow"));
62
+
63
+ const { uncovered, worstCheck } = selectUncoveredExternalPaths(
64
+ paths,
65
+ resolver,
66
+ undefined,
67
+ );
68
+
69
+ expect(uncovered).toEqual([]);
70
+ expect(worstCheck).toBeUndefined();
71
+ });
72
+
73
+ it("collects only paths whose resolved state is not allow", () => {
74
+ const allowed = AccessPath.forExternalDirectory("/outside/ok.ts", cwd);
75
+ const asked = AccessPath.forExternalDirectory("/outside/ask.ts", cwd);
76
+ const resolver = makeResolver();
77
+ resolver.resolvePathPolicy.mockImplementation(
78
+ (values: readonly string[]) =>
79
+ values.includes("/outside/ok.ts")
80
+ ? makeCheckResult("allow")
81
+ : makeCheckResult("ask"),
82
+ );
83
+
84
+ const { uncovered } = selectUncoveredExternalPaths(
85
+ [allowed, asked],
86
+ resolver,
87
+ undefined,
88
+ );
89
+
90
+ expect(uncovered.map(({ path }) => path.value())).toEqual([asked.value()]);
91
+ });
92
+
93
+ it("returns the most restrictive uncovered check as worstCheck (deny > ask)", () => {
94
+ const asked = AccessPath.forExternalDirectory("/outside/ask.ts", cwd);
95
+ const denied = AccessPath.forExternalDirectory("/outside/deny.ts", cwd);
96
+ const resolver = makeResolver();
97
+ resolver.resolvePathPolicy.mockImplementation(
98
+ (values: readonly string[]) =>
99
+ values.includes("/outside/deny.ts")
100
+ ? makeCheckResult("deny")
101
+ : makeCheckResult("ask"),
102
+ );
103
+
104
+ const { worstCheck } = selectUncoveredExternalPaths(
105
+ [asked, denied],
106
+ resolver,
107
+ undefined,
108
+ );
109
+
110
+ expect(worstCheck?.state).toBe("deny");
111
+ });
112
+ });
@@ -1,5 +1,6 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
+ import type { AccessPath } from "#src/access-intent/access-path";
3
4
  import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
4
5
 
5
6
  import {
@@ -24,7 +25,7 @@ function makeMockBashProgram() {
24
25
  return {
25
26
  commands: vi.fn<() => []>(() => []),
26
27
  pathRuleCandidates: vi.fn<() => []>(() => []),
27
- externalPaths: vi.fn<() => []>(() => []),
28
+ externalPaths: vi.fn<() => AccessPath[]>(() => []),
28
29
  };
29
30
  }
30
31
 
@@ -22,7 +22,6 @@ vi.mock("node:fs", () => ({
22
22
 
23
23
  import {
24
24
  canonicalNormalizePathForComparison,
25
- getExternalDirectoryPolicyValues,
26
25
  getPathBearingToolPath,
27
26
  getPathPolicyValues,
28
27
  getToolInputPath,
@@ -615,36 +614,3 @@ describe("getPathPolicyValues", () => {
615
614
  expect(getPathPolicyValues(" ", { cwd })).toEqual([]);
616
615
  });
617
616
  });
618
-
619
- describe("getExternalDirectoryPolicyValues", () => {
620
- const cwd = "/projects/my-app";
621
-
622
- beforeEach(() => {
623
- realpathSync.mockReset();
624
- realpathSync.mockImplementation((p: string) => p);
625
- });
626
-
627
- test("adds the symlink-resolved alias alongside the typed path", () => {
628
- // /tmp -> /private/tmp (the macOS symlink from the bug report).
629
- realpathSync.mockImplementation((p: string) =>
630
- p.startsWith("/tmp") ? `/private${p}` : p,
631
- );
632
- expect(getExternalDirectoryPolicyValues("/tmp/x", cwd)).toEqual([
633
- "/tmp/x",
634
- "/private/tmp/x",
635
- ]);
636
- });
637
-
638
- test("dedups when the canonical form equals the lexical form", () => {
639
- expect(getExternalDirectoryPolicyValues("/etc/hosts", cwd)).toEqual([
640
- "/etc/hosts",
641
- ]);
642
- });
643
-
644
- test("keeps the relative aliases for an in-cwd token without duplicating", () => {
645
- expect(getExternalDirectoryPolicyValues("src/foo.ts", cwd)).toEqual([
646
- "/projects/my-app/src/foo.ts",
647
- "src/foo.ts",
648
- ]);
649
- });
650
- });