@gotgenes/pi-permission-system 16.2.1 → 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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ 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
+ ## [17.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.2.1...pi-permission-system-v17.0.0) (2026-06-27)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * **pi-permission-system:** the path surface now also matches the canonical (symlink-resolved) form of bash path-rule tokens, so a path rule can fire on a symlink alias it previously missed, changing decisions on upgrade with no config edit.
14
+ * **pi-permission-system:** the path surface now also matches the canonical (symlink-resolved) form of a tool's file path. A path rule that previously matched only the as-typed spelling now also matches when the path resolves through a symlink to a target the pattern covers, which can change allow/deny decisions on upgrade with no config edit.
15
+
16
+ ### Features
17
+
18
+ * **pi-permission-system:** add AccessPath.forPath and forLiteral factories ([4323cae](https://github.com/gotgenes/pi-packages/commit/4323cae859907dd62d9cb401c194443717ab752c)), closes [#486](https://github.com/gotgenes/pi-packages/issues/486)
19
+ * **pi-permission-system:** match the canonical form on the bash-path gate ([6ce0c06](https://github.com/gotgenes/pi-packages/commit/6ce0c06b35c12eca32ffce5d4fe974c2f2dee393))
20
+ * **pi-permission-system:** match the canonical form on the path tool gate ([869ca76](https://github.com/gotgenes/pi-packages/commit/869ca761cf3d7a8367eb932106065a866d996ce4))
21
+
22
+
23
+ ### Documentation
24
+
25
+ * **pi-permission-system:** document canonical path-surface matching ([9606dce](https://github.com/gotgenes/pi-packages/commit/9606dce744bc1f95bd8424d2bb115a376d9a1191)), closes [#486](https://github.com/gotgenes/pi-packages/issues/486)
26
+
8
27
  ## [16.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.2.0...pi-permission-system-v16.2.1) (2026-06-27)
9
28
 
10
29
 
package/README.md CHANGED
@@ -17,7 +17,7 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
17
17
  - **Enforces allow / ask / deny** at tool-call time with UI confirmation dialogs
18
18
  - **Controls bash commands** with wildcard pattern matching (`git *: ask`, `rm -rf *: deny`)
19
19
  - **Gates MCP and skill access** at server, tool, and skill-name granularity
20
- - **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once
20
+ - **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once, matching both the path as referenced and its symlink-resolved form so a deny cannot be evaded through a symlink alias
21
21
  - **Guards external paths** — prompts before file tools or bash commands reach outside `cwd`
22
22
  - **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry), and an unparseable bash command — or an opaque `bash -c`/`eval` wrapper — prompts (`ask`) rather than passing silently
23
23
  - **Forwards prompts from subagents** — `ask` policies work even in non-UI execution contexts
@@ -69,6 +69,7 @@ See [docs/session-approvals.md](docs/session-approvals.md) for details on sessio
69
69
 
70
70
  The `path` surface is a cross-cutting gate that applies to **all** file access — Pi tools, bash commands, MCP calls, and extension tools alike.
71
71
  Extension and MCP tools that operate on paths (via `input.path`, MCP's `input.arguments.path`, or a registered access extractor) are gated by default, so a `path` deny cannot be overridden by a per-tool allow — making it the right place to protect sensitive files like `.env` or `~/.ssh/*` from every tool at once.
72
+ A `path` pattern matches both the path as the agent references it and its canonical (symlink-resolved) form, so a deny still fires when a symlink aliases a sensitive target.
72
73
 
73
74
  For per-tool path patterns (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`.
74
75
  This lets you express rules like "allow reads but deny `.env` files" at the individual tool level.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "16.2.1",
3
+ "version": "17.0.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,8 +17,9 @@ export interface ToolAccessIntent {
17
17
  /**
18
18
  * Precomputed equivalent policy values for a path-shaped surface.
19
19
  *
20
- * Used by the bash-path gate (`path` surface), whose cd-resolved lexical
21
- * `string[]` carry no canonical-boundary notion.
20
+ * Not gate-emitted: the resolver produces it internally by unwrapping an
21
+ * `access-path` intent via `matchValues()`, keeping the low-level manager
22
+ * string-based (it never imports `AccessPath`). See {@link ResolvedAccessIntent}.
22
23
  */
23
24
  export interface PathValuesAccessIntent {
24
25
  kind: "path-values";
@@ -31,9 +32,9 @@ export interface PathValuesAccessIntent {
31
32
  /**
32
33
  * An `AccessPath` value object for a path-shaped surface.
33
34
  *
34
- * Used by the external-directory gates; lets `AccessPath` flow into the
35
- * resolver as a first-class variant so the resolver — not the gate — asks it
36
- * for `matchValues()` (Tell-Don't-Ask).
35
+ * Emitted by every path gate (the `path` and `external_directory` surfaces);
36
+ * lets `AccessPath` flow into the resolver as a first-class variant so the
37
+ * resolver — not the gate — asks it for `matchValues()` (Tell-Don't-Ask).
37
38
  */
38
39
  export interface AccessPathAccessIntent {
39
40
  kind: "access-path";
@@ -42,11 +43,8 @@ export interface AccessPathAccessIntent {
42
43
  agentName?: string;
43
44
  }
44
45
 
45
- /** What a gate emits — the full three-variant union. */
46
- export type AccessIntent =
47
- | ToolAccessIntent
48
- | PathValuesAccessIntent
49
- | AccessPathAccessIntent;
46
+ /** What a gate emits — a raw tool input or an `AccessPath`. */
47
+ export type AccessIntent = ToolAccessIntent | AccessPathAccessIntent;
50
48
 
51
49
  /**
52
50
  * What the manager consumes — the `access-path` variant has already been
@@ -20,7 +20,9 @@ import {
20
20
  * - {@link value} returns `string` — the lexical absolute form, for display,
21
21
  * approval patterns, decision values, and logs.
22
22
  *
23
- * Construct via {@link forExternalDirectory}; the constructor is private.
23
+ * Construct via {@link forPath} (resolved, with optional cd-folded base) or
24
+ * {@link forLiteral} (literal-only, for an unknown base); the constructor is
25
+ * private.
24
26
  */
25
27
  export class AccessPath {
26
28
  private constructor(
@@ -65,24 +67,45 @@ export class AccessPath {
65
67
  }
66
68
 
67
69
  /**
68
- * Build an `AccessPath` for a tool-input or bash-token path resolved against
69
- * `cwd`.
70
+ * Build an `AccessPath` for a tool-input or bash-token path, resolved against
71
+ * `resolveBase` (the cd-folded effective directory; defaults to `cwd`).
70
72
  *
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.
73
+ * Serves every path surface: the tool path gate, the tool external-directory
74
+ * gate, and the bash path/external-directory gates (which pass a cd-resolved
75
+ * `resolveBase`).
76
+ *
77
+ * - `matchValues()` returns the lexical alias union from `getPathPolicyValues`
78
+ * plus the canonical alias from `canonicalNormalizePathForComparison`
79
+ * (#418), so a config pattern on either the typed or symlink-resolved form
80
+ * matches.
81
+ * - `boundaryValue()` returns
82
+ * `canonicalNormalizePathForComparison(pathValue, resolveBase)`, which is
83
+ * win32-lowercased (#382) — do not substitute a raw `canonicalizePath`
84
+ * output here.
85
+ * - `value()` returns `normalizePathForComparison(pathValue, resolveBase)`,
86
+ * the absolute lexical form.
80
87
  */
81
- static forExternalDirectory(pathValue: string, cwd: string): AccessPath {
88
+ static forPath(
89
+ pathValue: string,
90
+ options: { cwd: string; resolveBase?: string },
91
+ ): AccessPath {
92
+ const { cwd, resolveBase = cwd } = options;
82
93
  return new AccessPath(
83
- normalizePathForComparison(pathValue, cwd),
84
- getPathPolicyValues(pathValue, { cwd }),
85
- canonicalNormalizePathForComparison(pathValue, cwd),
94
+ normalizePathForComparison(pathValue, resolveBase),
95
+ getPathPolicyValues(pathValue, { cwd, resolveBase }),
96
+ canonicalNormalizePathForComparison(pathValue, resolveBase),
86
97
  );
87
98
  }
99
+
100
+ /**
101
+ * Build a literal-only `AccessPath` for a path whose effective base is
102
+ * unknown (a relative bash token after a non-literal `cd`).
103
+ *
104
+ * Carries no canonical alias and no absolute resolution — `matchValues()` is
105
+ * `[literal]` (or `[]` when empty) and `boundaryValue()` is `""` — so no
106
+ * spurious absolute or symlink-resolved rule can match (#393).
107
+ */
108
+ static forLiteral(literal: string): AccessPath {
109
+ return new AccessPath(literal, literal ? [literal] : [], "");
110
+ }
88
111
  }
@@ -17,7 +17,6 @@ import {
17
17
  } from "#src/access-intent/bash/token-collection";
18
18
  import { canonicalizePath } from "#src/canonicalize-path";
19
19
  import {
20
- getPathPolicyValues,
21
20
  isPathWithinDirectory,
22
21
  isSafeSystemPath,
23
22
  normalizePathForComparison,
@@ -54,8 +53,8 @@ interface PathCandidate {
54
53
  export interface BashPathRuleCandidate {
55
54
  /** Raw path-like token shown in prompts, logs, and session approvals. */
56
55
  readonly token: string;
57
- /** Equivalent values used for permission policy matching. */
58
- readonly policyValues: readonly string[];
56
+ /** The path's lexical and canonical forms for permission policy matching. */
57
+ readonly path: AccessPath;
59
58
  }
60
59
 
61
60
  // ── Walk-time constants ──────────────────────────────────────────────────────
@@ -379,18 +378,20 @@ function isRelativeCandidate(candidate: string): boolean {
379
378
  return !candidate.startsWith("/") && !candidate.startsWith("~");
380
379
  }
381
380
 
382
- function getPolicyValuesForRuleCandidate(
381
+ function buildRuleCandidatePath(
383
382
  candidate: string,
384
383
  base: EffectiveBase,
385
384
  cwd: string,
386
- ): string[] {
385
+ ): AccessPath {
386
+ // An unknown base + relative candidate stays literal-only: a resolved
387
+ // absolute or canonical alias would resolve against the wrong directory and
388
+ // could spuriously match a rule (#393).
387
389
  if (base.kind === "unknown" && isRelativeCandidate(candidate)) {
388
- const literal = normalizePathPolicyLiteral(candidate);
389
- return literal ? [literal] : [];
390
+ return AccessPath.forLiteral(normalizePathPolicyLiteral(candidate));
390
391
  }
391
392
 
392
393
  const resolveBase = base.kind === "known" ? resolve(cwd, base.offset) : cwd;
393
- return getPathPolicyValues(candidate, { cwd, resolveBase });
394
+ return AccessPath.forPath(candidate, { cwd, resolveBase });
394
395
  }
395
396
 
396
397
  // ── Projection functions ─────────────────────────────────────────────────────
@@ -435,7 +436,7 @@ export function projectExternalPaths(
435
436
  seen.add(canonical);
436
437
  // The factory recomputes the canonical via canonicalNormalizePathForComparison
437
438
  // (win32-lowercased, #382) rather than reusing the raw canonicalizePath output.
438
- externalPaths.push(AccessPath.forExternalDirectory(lexical, cwd));
439
+ externalPaths.push(AccessPath.forPath(lexical, { cwd }));
439
440
  }
440
441
  continue;
441
442
  }
@@ -457,7 +458,7 @@ export function projectExternalPaths(
457
458
  seen.add(canonical);
458
459
  // The factory recomputes the canonical via canonicalNormalizePathForComparison
459
460
  // (win32-lowercased, #382) rather than reusing the raw canonicalizePath output.
460
- externalPaths.push(AccessPath.forExternalDirectory(lexical, cwd));
461
+ externalPaths.push(AccessPath.forPath(lexical, { cwd }));
461
462
  }
462
463
  }
463
464
 
@@ -485,13 +486,14 @@ export function projectRuleCandidates(
485
486
  const candidate = classifyTokenAsRuleCandidate(token);
486
487
  if (!candidate) continue;
487
488
 
488
- const policyValues = getPolicyValuesForRuleCandidate(candidate, base, cwd);
489
- if (policyValues.length === 0) continue;
489
+ const path = buildRuleCandidatePath(candidate, base, cwd);
490
+ const matchValues = path.matchValues();
491
+ if (matchValues.length === 0) continue;
490
492
 
491
- const key = policyValues.join("\0");
493
+ const key = matchValues.join("\0");
492
494
  if (seen.has(key)) continue;
493
495
  seen.add(key);
494
- result.push({ token: candidate, policyValues });
496
+ result.push({ token: candidate, path });
495
497
  }
496
498
 
497
499
  return result;
@@ -1,3 +1,4 @@
1
+ import type { AccessPath } from "#src/access-intent/access-path";
1
2
  import type { BashProgram } from "#src/access-intent/bash/program";
2
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
3
4
  import { SessionApproval } from "#src/session-approval";
@@ -41,20 +42,20 @@ export function describeBashPathGate(
41
42
  const tokens = candidates.map(({ token }) => token);
42
43
 
43
44
  // Tokens whose resolved state needs a check (deny/ask), paired with the raw
44
- // token (prompt/decision display) and its policy values (the first of which
45
- // is the canonical absolute path the approval pattern is derived from).
45
+ // token (prompt/decision display) and its `AccessPath` (whose `value()` is
46
+ // the lexical absolute path the approval pattern is derived from).
46
47
  const uncovered: Array<{
47
48
  token: string;
48
- policyValues: readonly string[];
49
+ path: AccessPath;
49
50
  check: PermissionCheckResult;
50
51
  }> = [];
51
52
  let allSessionCovered = true;
52
53
 
53
- for (const { token, policyValues } of candidates) {
54
+ for (const { token, path } of candidates) {
54
55
  const check = resolver.resolve({
55
- kind: "path-values",
56
+ kind: "access-path",
56
57
  surface: "path",
57
- values: policyValues,
58
+ path,
58
59
  agentName: tcc.agentName ?? undefined,
59
60
  });
60
61
 
@@ -71,11 +72,11 @@ export function describeBashPathGate(
71
72
  }
72
73
 
73
74
  if (check.state === "deny") {
74
- uncovered.push({ token, policyValues, check });
75
+ uncovered.push({ token, path, check });
75
76
  break; // Short-circuit on deny.
76
77
  }
77
78
  if (check.state === "ask") {
78
- uncovered.push({ token, policyValues, check });
79
+ uncovered.push({ token, path, check });
79
80
  }
80
81
  }
81
82
 
@@ -108,11 +109,10 @@ export function describeBashPathGate(
108
109
  // All tokens evaluate to allow — no restriction.
109
110
  if (!worstCheck || !worstToken || !worstEntry) return null;
110
111
 
111
- // Derive the pattern from the canonical absolute policy value (the cd-aware
112
- // resolved path), so it matches the values a later call produces. Falls back
113
- // to the raw token only when no base was resolvable (no cwd / unknown cd).
114
- const approvalBase = worstEntry.policyValues[0] ?? worstToken;
115
- const pattern = deriveApprovalPattern(approvalBase);
112
+ // Derive the pattern from the lexical absolute form (the cd-aware resolved
113
+ // path), so it matches the values a later call produces. For an unknown base
114
+ // (`forLiteral`) `value()` is the raw token.
115
+ const pattern = deriveApprovalPattern(worstEntry.path.value());
116
116
  const askMessage = formatPathAskPrompt(
117
117
  tcc.toolName,
118
118
  worstToken,
@@ -42,10 +42,9 @@ 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 accessPath = AccessPath.forExternalDirectory(
46
- externalDirectoryPath,
47
- tcc.cwd,
48
- );
45
+ const accessPath = AccessPath.forPath(externalDirectoryPath, {
46
+ cwd: tcc.cwd,
47
+ });
49
48
  const canonicalExtPath = accessPath.boundaryValue();
50
49
 
51
50
  // ── Pi infrastructure read bypass ──────────────────────────────────────
@@ -1,4 +1,5 @@
1
- import { getToolInputPath, normalizePathForComparison } from "#src/path-utils";
1
+ import { AccessPath } from "#src/access-intent/access-path";
2
+ import { getToolInputPath } from "#src/path-utils";
2
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
3
4
  import { SessionApproval } from "#src/session-approval";
4
5
  import { deriveApprovalPattern } from "#src/session-rules";
@@ -22,10 +23,14 @@ export function describePathGate(
22
23
  const filePath = getToolInputPath(tcc.toolName, tcc.input, extractors);
23
24
  if (!filePath) return null;
24
25
 
26
+ // Emit an access-path intent so the resolver matches the lexical aliases
27
+ // *and* the canonical (symlink-resolved) form, the same set
28
+ // `external_directory` matches (#418, #486).
29
+ const accessPath = AccessPath.forPath(filePath, { cwd: tcc.cwd });
25
30
  const check = resolver.resolve({
26
- kind: "tool",
31
+ kind: "access-path",
27
32
  surface: "path",
28
- input: { path: filePath },
33
+ path: accessPath,
29
34
  agentName: tcc.agentName ?? undefined,
30
35
  });
31
36
 
@@ -36,10 +41,9 @@ export function describePathGate(
36
41
  // "path" key should not trigger path-level prompts (#58).
37
42
  if (check.matchedPattern === undefined) return null;
38
43
 
39
- // Resolve to the canonical (cwd-anchored, absolute) path so the approval
40
- // pattern matches the policy values a later call produces.
41
- const approvalPath = normalizePathForComparison(filePath, tcc.cwd);
42
- const pattern = deriveApprovalPattern(approvalPath);
44
+ // Derive the approval pattern from the lexical absolute form so it matches
45
+ // the policy values a later call produces.
46
+ const pattern = deriveApprovalPattern(accessPath.value());
43
47
 
44
48
  const descriptor: GateDescriptor = {
45
49
  surface: "path",
@@ -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
+ });
@@ -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
 
@@ -44,7 +44,6 @@ function makeCheckResult(
44
44
  /** Extract the policy match values a resolve(intent) call carries. */
45
45
  function intentValues(intent: AccessIntent): readonly string[] {
46
46
  if (intent.kind === "access-path") return intent.path.matchValues();
47
- if (intent.kind === "path-values") return intent.values;
48
47
  return [];
49
48
  }
50
49
 
@@ -9,6 +9,7 @@ vi.mock("node:os", () => {
9
9
  };
10
10
  });
11
11
 
12
+ import { AccessPath } from "#src/access-intent/access-path";
12
13
  import { BashProgram } from "#src/access-intent/bash/program";
13
14
  import { describeBashPathGate } from "#src/handlers/gates/bash-path";
14
15
  import type {
@@ -229,13 +230,12 @@ describe("describeBashPathGate", () => {
229
230
  )) as GateDescriptor;
230
231
 
231
232
  expect(resolver.resolve).toHaveBeenCalledWith({
232
- kind: "path-values",
233
+ kind: "access-path",
233
234
  surface: "path",
234
- values: [
235
- "/test/project/nested/src/file.txt",
236
- "nested/src/file.txt",
237
- "src/file.txt",
238
- ],
235
+ path: AccessPath.forPath("src/file.txt", {
236
+ cwd: "/test/project",
237
+ resolveBase: "/test/project/nested",
238
+ }),
239
239
  agentName: undefined,
240
240
  });
241
241
  // The raw token drives the prompt, denial context, and session approval.
@@ -256,9 +256,9 @@ describe("describeBashPathGate", () => {
256
256
  );
257
257
 
258
258
  expect(resolver.resolve).toHaveBeenCalledWith({
259
- kind: "path-values",
259
+ kind: "access-path",
260
260
  surface: "path",
261
- values: ["src/foo.ts"],
261
+ path: AccessPath.forLiteral("src/foo.ts"),
262
262
  agentName: undefined,
263
263
  });
264
264
  });
@@ -25,7 +25,7 @@ function makeCheckResult(
25
25
 
26
26
  describe("resolveExternalDirectoryPolicy", () => {
27
27
  it("resolves the path's match aliases on the external_directory surface (#418)", () => {
28
- const path = AccessPath.forExternalDirectory("/outside/a.ts", cwd);
28
+ const path = AccessPath.forPath("/outside/a.ts", { cwd });
29
29
  const resolver = makeResolver(makeCheckResult("ask"));
30
30
 
31
31
  const result = resolveExternalDirectoryPolicy(path, resolver, undefined);
@@ -40,7 +40,7 @@ describe("resolveExternalDirectoryPolicy", () => {
40
40
  });
41
41
 
42
42
  it("threads the agent name through to the resolver", () => {
43
- const path = AccessPath.forExternalDirectory("/outside/a.ts", cwd);
43
+ const path = AccessPath.forPath("/outside/a.ts", { cwd });
44
44
  const resolver = makeResolver(makeCheckResult("allow"));
45
45
 
46
46
  resolveExternalDirectoryPolicy(path, resolver, "reviewer");
@@ -57,8 +57,8 @@ describe("resolveExternalDirectoryPolicy", () => {
57
57
  describe("selectUncoveredExternalPaths", () => {
58
58
  it("returns no uncovered paths when every path resolves to allow", () => {
59
59
  const paths = [
60
- AccessPath.forExternalDirectory("/outside/a.ts", cwd),
61
- AccessPath.forExternalDirectory("/outside/b.ts", cwd),
60
+ AccessPath.forPath("/outside/a.ts", { cwd }),
61
+ AccessPath.forPath("/outside/b.ts", { cwd }),
62
62
  ];
63
63
  const resolver = makeResolver(makeCheckResult("allow"));
64
64
 
@@ -73,16 +73,12 @@ describe("selectUncoveredExternalPaths", () => {
73
73
  });
74
74
 
75
75
  it("collects only paths whose resolved state is not allow", () => {
76
- const allowed = AccessPath.forExternalDirectory("/outside/ok.ts", cwd);
77
- const asked = AccessPath.forExternalDirectory("/outside/ask.ts", cwd);
76
+ const allowed = AccessPath.forPath("/outside/ok.ts", { cwd });
77
+ const asked = AccessPath.forPath("/outside/ask.ts", { cwd });
78
78
  const resolver = makeResolver();
79
79
  resolver.resolve.mockImplementation((intent) => {
80
80
  const values =
81
- intent.kind === "access-path"
82
- ? intent.path.matchValues()
83
- : intent.kind === "path-values"
84
- ? intent.values
85
- : [];
81
+ intent.kind === "access-path" ? intent.path.matchValues() : [];
86
82
  return values.includes("/outside/ok.ts")
87
83
  ? makeCheckResult("allow")
88
84
  : makeCheckResult("ask");
@@ -98,16 +94,12 @@ describe("selectUncoveredExternalPaths", () => {
98
94
  });
99
95
 
100
96
  it("returns the most restrictive uncovered check as worstCheck (deny > ask)", () => {
101
- const asked = AccessPath.forExternalDirectory("/outside/ask.ts", cwd);
102
- const denied = AccessPath.forExternalDirectory("/outside/deny.ts", cwd);
97
+ const asked = AccessPath.forPath("/outside/ask.ts", { cwd });
98
+ const denied = AccessPath.forPath("/outside/deny.ts", { cwd });
103
99
  const resolver = makeResolver();
104
100
  resolver.resolve.mockImplementation((intent) => {
105
101
  const values =
106
- intent.kind === "access-path"
107
- ? intent.path.matchValues()
108
- : intent.kind === "path-values"
109
- ? intent.values
110
- : [];
102
+ intent.kind === "access-path" ? intent.path.matchValues() : [];
111
103
  return values.includes("/outside/deny.ts")
112
104
  ? makeCheckResult("deny")
113
105
  : makeCheckResult("ask");
@@ -1,5 +1,16 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
+ // Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
4
+ // Default implementation is identity — lexical tests are unaffected.
5
+ const realpathSync = vi.hoisted(() =>
6
+ vi.fn<(path: string) => string>((p) => p),
7
+ );
8
+ vi.mock("node:fs", () => ({
9
+ realpathSync,
10
+ default: { realpathSync },
11
+ }));
12
+
13
+ import { AccessPath } from "#src/access-intent/access-path";
3
14
  import type { GateDescriptor } from "#src/handlers/gates/descriptor";
4
15
  import { isGateDescriptor } from "#src/handlers/gates/descriptor";
5
16
  import { describePathGate } from "#src/handlers/gates/path";
@@ -27,6 +38,11 @@ function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
27
38
  // ── tests ──────────────────────────────────────────────────────────────────
28
39
 
29
40
  describe("describePathGate", () => {
41
+ beforeEach(() => {
42
+ realpathSync.mockReset();
43
+ realpathSync.mockImplementation((p: string) => p);
44
+ });
45
+
30
46
  it("returns null for non-path-bearing tools", () => {
31
47
  const resolver = makeResolver();
32
48
  const result = describePathGate(
@@ -152,16 +168,31 @@ describe("describePathGate", () => {
152
168
  expect(result.decision.value).toBe(".env");
153
169
  });
154
170
 
155
- it("resolves the path surface with the file path and agent name", () => {
171
+ it("resolves the path surface with an access-path intent and agent name", () => {
156
172
  const resolver = makeResolver(makeCheckResult({ state: "allow" }));
157
173
  describePathGate(makeTcc({ agentName: "my-agent" }), resolver);
158
174
  expect(resolver.resolve).toHaveBeenCalledWith({
159
- kind: "tool",
175
+ kind: "access-path",
160
176
  surface: "path",
161
- input: { path: ".env" },
177
+ path: AccessPath.forPath(".env", { cwd: "/test/project" }),
162
178
  agentName: "my-agent",
163
179
  });
164
180
  });
181
+
182
+ it("emits an access-path whose matchValues include the symlink-resolved form (#486)", () => {
183
+ // /test/project/.env is a symlink to /vault/secret.env.
184
+ realpathSync.mockImplementation((p: string) =>
185
+ p === "/test/project/.env" ? "/vault/secret.env" : p,
186
+ );
187
+ const resolver = makeResolver(makeCheckResult({ state: "allow" }));
188
+ describePathGate(makeTcc(), resolver);
189
+
190
+ const intent = resolver.resolve.mock.lastCall?.[0];
191
+ expect(intent?.kind).toBe("access-path");
192
+ expect(intent?.kind === "access-path" && intent.path.matchValues()).toEqual(
193
+ ["/test/project/.env", ".env", "/vault/secret.env"],
194
+ );
195
+ });
165
196
  });
166
197
 
167
198
  // Home-relative path characterization (#350) ──────────────────────────────
@@ -189,9 +220,9 @@ describe("describePathGate — home-relative paths", () => {
189
220
  pathValue: "~/.ssh/config",
190
221
  });
191
222
  expect(resolver.resolve).toHaveBeenCalledWith({
192
- kind: "tool",
223
+ kind: "access-path",
193
224
  surface: "path",
194
- input: { path: "~/.ssh/config" },
225
+ path: AccessPath.forPath("~/.ssh/config", { cwd: "/test/project" }),
195
226
  agentName: undefined,
196
227
  });
197
228
  });
@@ -246,9 +277,9 @@ describe("describePathGate — extension and MCP tools (#352)", () => {
246
277
  );
247
278
  expect(isGateDescriptor(result)).toBe(true);
248
279
  expect(resolver.resolve).toHaveBeenCalledWith({
249
- kind: "tool",
280
+ kind: "access-path",
250
281
  surface: "path",
251
- input: { path: ".env" },
282
+ path: AccessPath.forPath(".env", { cwd: "/test/project" }),
252
283
  agentName: undefined,
253
284
  });
254
285
  });
@@ -263,9 +294,9 @@ describe("describePathGate — extension and MCP tools (#352)", () => {
263
294
  );
264
295
  expect(isGateDescriptor(result)).toBe(true);
265
296
  expect(resolver.resolve).toHaveBeenCalledWith({
266
- kind: "tool",
297
+ kind: "access-path",
267
298
  surface: "path",
268
- input: { path: ".env" },
299
+ path: AccessPath.forPath(".env", { cwd: "/test/project" }),
269
300
  agentName: undefined,
270
301
  });
271
302
  });
@@ -280,9 +311,9 @@ describe("describePathGate — extension and MCP tools (#352)", () => {
280
311
  extractorLookup("ffgrep", "target"),
281
312
  );
282
313
  expect(resolver.resolve).toHaveBeenCalledWith({
283
- kind: "tool",
314
+ kind: "access-path",
284
315
  surface: "path",
285
- input: { path: "/etc/passwd" },
316
+ path: AccessPath.forPath("/etc/passwd", { cwd: "/test/project" }),
286
317
  agentName: undefined,
287
318
  });
288
319
  });
@@ -213,8 +213,7 @@ export function makePathDispatchResolver(
213
213
  }
214
214
  return defaultResult;
215
215
  }
216
- const values =
217
- intent.kind === "access-path" ? intent.path.matchValues() : intent.values;
216
+ const values = intent.path.matchValues();
218
217
  for (const value of values) {
219
218
  if (value in byPath) return byPath[value];
220
219
  }
@@ -120,28 +120,7 @@ describe("PermissionResolver", () => {
120
120
  });
121
121
  });
122
122
 
123
- describe("resolve — path-values intent", () => {
124
- it("forwards a path-values intent with the current session ruleset", () => {
125
- const { resolver, permissionManager } = makeResolver();
126
-
127
- resolver.resolve({
128
- kind: "path-values",
129
- surface: "path",
130
- values: ["/proj/src/a.ts", "src/a.ts"],
131
- agentName: "agent-x",
132
- });
133
-
134
- expect(permissionManager.check).toHaveBeenCalledWith(
135
- {
136
- kind: "path-values",
137
- surface: "path",
138
- values: ["/proj/src/a.ts", "src/a.ts"],
139
- agentName: "agent-x",
140
- },
141
- [],
142
- );
143
- });
144
-
123
+ describe("resolve — session ruleset threading", () => {
145
124
  it("applies a recorded session approval on the next call", () => {
146
125
  const pm = makePermissionManager();
147
126
  const sessionRules = new SessionRules();
@@ -151,9 +130,9 @@ describe("PermissionResolver", () => {
151
130
  SessionApproval.single("path", "src/*"),
152
131
  );
153
132
  resolver.resolve({
154
- kind: "path-values",
133
+ kind: "access-path",
155
134
  surface: "path",
156
- values: ["src/a.ts"],
135
+ path: AccessPath.forPath("src/a.ts", { cwd: "/proj" }),
157
136
  });
158
137
 
159
138
  const passedRules = vi.mocked(pm.check).mock.calls[0][1];
@@ -169,10 +148,7 @@ describe("PermissionResolver", () => {
169
148
  describe("resolve — access-path intent", () => {
170
149
  it("unwraps the AccessPath via matchValues() into a path-values intent", () => {
171
150
  const { resolver, permissionManager } = makeResolver();
172
- const accessPath = AccessPath.forExternalDirectory(
173
- "/tmp/x",
174
- "/workspace",
175
- );
151
+ const accessPath = AccessPath.forPath("/tmp/x", { cwd: "/workspace" });
176
152
 
177
153
  resolver.resolve({
178
154
  kind: "access-path",
@@ -202,10 +178,7 @@ describe("PermissionResolver", () => {
202
178
  matchedPattern: "/tmp/*",
203
179
  });
204
180
  const { resolver } = makeResolver(pm);
205
- const accessPath = AccessPath.forExternalDirectory(
206
- "/tmp/x",
207
- "/workspace",
208
- );
181
+ const accessPath = AccessPath.forPath("/tmp/x", { cwd: "/workspace" });
209
182
 
210
183
  const result = resolver.resolve({
211
184
  kind: "access-path",