Files
my-pi/pi-permission-system/docs/plans/0486-path-surface-canonical-matching.md

20 KiB
Raw Permalink Blame History

issue, issue_title
issue issue_title
486 pi-permission-system: should the path surface match the canonical (symlink-resolved) form like external_directory?

Make the path surface match the canonical (symlink-resolved) form

Release Recommendation

Release: ship independently

Issue #486 is a follow-on filed and deferred during Phase 6 (#478); it is not a member of any active roadmap batch, and Phase 6 is closed. It is a self-contained behavior change to one surface, so it ships on its own — and because it is breaking (see Goals), it warrants its own major-bump release rather than batching.

Problem Statement

The path and external_directory surfaces match against different value sets today:

  • path matches the lexical aliases only — the as-typed form and its cwd/effective-base absolute resolution (getPathPolicyValues).
  • external_directory matches the lexical aliases plus the canonical (symlink-resolved) form (AccessPath.matchValues()), the #418 fix: a rule keyed on /tmp/* matches even when the access resolves to /private/tmp.

This asymmetry means a path deny on a sensitive spelling (*.env, ~/.ssh/*) can be evaded through a symlink alias, whereas the same rule on external_directory cannot. The operator has decided (issue thread) that path should also match the canonical form, so a path deny on /etc/passwd catches a symlink to it. After this change the two surfaces match the identical value set and the asymmetry dissolves.

Goals

  • Make the path surface match the lexical aliases plus the canonical (symlink-resolved) form — the same set AccessPath.matchValues() already computes — across both producers (the tool path gate and the bash-path gate), so no new tool-vs-bash asymmetry is introduced.
  • Route both path producers through AccessPath (the "full" scope the operator chose), pulling the bash-path AccessPath migration forward from #487.
  • Collapse the now-unproduced emitted path-values AccessIntent variant, completing one item the #487 direction listed.
  • Preserve the #393 unknown-base behavior (a relative bash token after a non-literal cd keeps its literal value only — no canonical, no spurious absolute) and the #418 / #382 external-directory invariants.

This is a breaking change: adding the canonical alias to the path match set alters which rules fire on upgrade with no user edit. A symlink whose resolved target matches a path deny (or allow) pattern now matches it where it previously did not. The suggested commit for the behavior step is feat(pi-permission-system)!: with a BREAKING CHANGE: footer.

Non-Goals

  • No migration of config-pattern or prompt-input path handling onto AccessPath — those remain the residual #487 scope after this change.
  • No change to what external_directory matches (it already matches lexical canonical); this change only brings path to parity.
  • No change to dedup/approval-key identity: keys continue to derive from the lexical form (AccessPath.value()), so existing session approvals stay stable.
  • No principal identity on AccessIntent; cross-session path portability stays deferred.

Background

Relevant modules (all in packages/pi-permission-system/):

  • src/access-intent/access-path.tsAccessPath value object. matchValues() returns lexical aliases canonical; boundaryValue() the canonical form; value() the lexical absolute form. Built today via the private constructor through forExternalDirectory(pathValue, cwd).
  • src/access-intent/access-intent.ts — the AccessIntent emitted union (tool | path-values | access-path) and the ResolvedAccessIntent manager-consumed union (tool | path-values).
  • src/permission-resolver.tstoResolvedIntent unwraps an access-path intent to path-values via matchValues() before handing it to the manager; the manager stays string-based.
  • src/permission-manager.tscheck(intent): the path-values branch evaluates intent.values directly against intent.surface; the tool branch normalizes raw input via normalizeInputnormalizePathSurfaceValuesgetPathPolicyValues (lexical only).
  • src/handlers/gates/path.ts — tool path gate. Emits { kind: "tool", surface: "path", input: { path } }; the manager normalizes it lexically.
  • src/handlers/gates/bash-path.ts — bash path gate. Emits { kind: "path-values", surface: "path", values }, the values coming from BashProgram.pathRuleCandidates().
  • src/access-intent/bash/cwd-projection.tsprojectRuleCandidates builds BashPathRuleCandidate[] ({ token, policyValues }); getPolicyValuesForRuleCandidate returns lexical values, and for an unknown base + relative candidate returns the literal only (#393).
  • src/handlers/gates/external-directory.ts, src/access-intent/bash/cwd-projection.ts (projectExternalPaths) — the existing forExternalDirectory callers (the external-directory surface).

Key constraint (AGENTS.md / SKILL): the manager stays string-based and never imports AccessPath; the resolver does the matchValues() unwrap. This change preserves that — both path producers emit access-path, the resolver unwraps, the manager is untouched.

The reason the asymmetry exists is documented in docs/architecture/architecture.md (the access-path narrative) and in the #478 retro: forcing bash-path through AccessPath would inject a canonical alias the path surface did not match — a behavior change deferred to this issue. That behavior change is now wanted.

Design Overview

The match set is already single-sourced

AccessPath.matchValues() returns exactly lexical aliases canonical — the set the path surface should now match. The resolver already unwraps an access-path intent through matchValues(). So the change is: make both path producers emit access-path instead of their lexical-only forms. No manager change is needed.

Factory: generalize forExternalDirectory to a surface-neutral forPath, add forLiteral

forExternalDirectory(pathValue, cwd) is no longer external-directory-specific. Generalize and rename it to a surface-neutral factory that also supports a cd-resolved base for bash candidates:

// access-path.ts
static forPath(
  pathValue: string,
  options: { cwd: string; resolveBase?: string },
): AccessPath {
  const { cwd, resolveBase = cwd } = options;
  return new AccessPath(
    normalizePathForComparison(pathValue, resolveBase),
    getPathPolicyValues(pathValue, { cwd, resolveBase }),
    canonicalNormalizePathForComparison(pathValue, resolveBase),
  );
}

// literal-only: the #393 unknown-base case — no absolute, no canonical
static forLiteral(literal: string): AccessPath {
  return new AccessPath(literal, literal ? [literal] : [], "");
}

forPath(p, { cwd }) (resolveBase defaults to cwd) is behavior-identical to the old forExternalDirectory(p, cwd): getPathPolicyValues(p, { cwd, resolveBase: cwd }) equals getPathPolicyValues(p, { cwd }) because resolveBase already defaults to cwd inside getAbsolutePathPolicyValues. So renaming the external-directory callers preserves their behavior.

forLiteral produces matchValues() === [literal], boundaryValue() === "", value() === literal — exactly the conservative unknown-base shape (matchValues() already collapses to the aliases when canonical is "").

Tool path gate (path.ts)

Build an AccessPath and emit an access-path intent on the path surface; derive the approval pattern from accessPath.value() (the lexical absolute, identical to today's normalizePathForComparison(filePath, tcc.cwd)):

const accessPath = AccessPath.forPath(filePath, { cwd: tcc.cwd });
const check = resolver.resolve({
  kind: "access-path",
  surface: "path",
  path: accessPath,
  agentName: tcc.agentName ?? undefined,
});
if (check.state === "allow") return null;
if (check.matchedPattern === undefined) return null; // #58 backward-compat guard, unchanged
const pattern = deriveApprovalPattern(accessPath.value());

The #58 guard (skip when only the universal default fired) is preserved — the resolved check still carries matchedPattern. Prompt/log/decision/denialContext keep using the raw filePath.

Bash path candidates (cwd-projection.tsbash-path.ts)

BashPathRuleCandidate carries an AccessPath instead of precomputed lexical policyValues, keeping the raw token for prompts/logs/approvals:

export interface BashPathRuleCandidate {
  readonly token: string;
  readonly path: AccessPath;
}

projectRuleCandidates builds each candidate's AccessPath, preserving the #393 unknown-base branch via forLiteral:

const path =
  base.kind === "unknown" && isRelativeCandidate(candidate)
    ? AccessPath.forLiteral(normalizePathPolicyLiteral(candidate))
    : AccessPath.forPath(candidate, {
        cwd,
        resolveBase: base.kind === "known" ? resolve(cwd, base.offset) : cwd,
      });
if (path.matchValues().length === 0) continue;
const key = path.matchValues().join("\0"); // dedup identity preserved

getPolicyValuesForRuleCandidate is dissolved into projectRuleCandidates (its sole caller) — its lexical/literal branching moves into the AccessPath construction above.

bash-path.ts emits access-path per candidate and derives the approval base from path.value():

for (const { token, path } of candidates) {
  const check = resolver.resolve({
    kind: "access-path",
    surface: "path",
    path,
    agentName: tcc.agentName ?? undefined,
  });
  // ...existing #58 guard, deny short-circuit, ask accumulation unchanged...
}
// approval base:
const approvalBase = worstEntry.path.value();

This honors Tell-Don't-Ask (the resolver asks the AccessPath for matchValues()) and keeps the manager string-based — identical to how the external-directory gates already work.

Collapse the emitted path-values variant

After both producers emit access-path, no gate emits path-values. Remove PathValuesAccessIntent from the emitted AccessIntent union while keeping it in ResolvedAccessIntent (the resolver still produces it internally via toResolvedIntent, and the manager still consumes it):

export type AccessIntent = ToolAccessIntent | AccessPathAccessIntent;
export type ResolvedAccessIntent = ToolAccessIntent | PathValuesAccessIntent;

toResolvedIntent now maps access-path → path-values and tool → tool; its prior path-values passthrough case is gone.

Edge cases

  • Not a symlink: matchValues() already collapses to the lexical aliases when canonical equals one of them — no spurious extra value.
  • Unresolvable path (empty / ELOOP / EACCES): canonicalNormalizePathForComparison falls back to the lexical form; forLiteral yields boundaryValue() === "". No new match introduced beyond today's lexical behavior.
  • #58 no-path-key configs: unchanged — the matchedPattern === undefined guard still short-circuits.
  • Dedup / session approvals: keys derive from value() (lexical), unchanged.

Module-Level Changes

  • src/access-intent/access-path.ts — rename/generalize forExternalDirectory(pathValue, cwd)forPath(pathValue, { cwd, resolveBase? }); add forLiteral(literal). Update the class doc comment (it names forExternalDirectory).
  • src/access-intent/access-intent.ts — remove PathValuesAccessIntent from the emitted AccessIntent union (keep the interface and its place in ResolvedAccessIntent); update doc comments that describe path-values as an emitted variant.
  • src/permission-resolver.tstoResolvedIntent drops the path-values passthrough branch (now tool | access-path input only).
  • src/handlers/gates/path.ts — build AccessPath.forPath, emit access-path on path, derive pattern from accessPath.value().
  • src/handlers/gates/bash-path.ts — consume { token, path } candidates, emit access-path per candidate, derive approvalBase from path.value().
  • src/access-intent/bash/cwd-projection.tsBashPathRuleCandidate becomes { token, path: AccessPath }; projectRuleCandidates builds AccessPath (via forPath / forLiteral); inline and remove getPolicyValuesForRuleCandidate. Rename the existing projectExternalPaths forExternalDirectory call sites (2) to forPath.
  • src/access-intent/bash/program.ts — re-exports BashPathRuleCandidate (shape change flows through; verify no other change needed).
  • src/handlers/gates/external-directory.ts — rename the forExternalDirectory call to forPath.

Documentation (grep-verified — symbol/behavior is named in prose):

  • docs/configuration.mdpath Surface section: update "matches as the agent references it" framing to state it now also matches the symlink-resolved form; add a path-surface symlink note (or generalize the existing external_directory "Symlinked paths" note at line ~465 to cover both surfaces).
  • docs/architecture/architecture.md — update: the access-path.ts module entry (factory name/desc, line ~670), the bash-path.ts entry (line ~696, now emits access-path), the cwd-projection.ts entry (pathRuleCandidates() returns AccessPath-backed candidates), the access-intent.ts entry (emitted union no longer carries path-values), and the Phase 6 follow-on note (line ~760: #486 implemented, #487 narrowed to config-pattern/prompt-input migration). Verify the inline Rule/Ruleset type listings are untouched (they are — no rule-type field changes here).
  • .pi/skills/package-pi-permission-system/SKILL.md — update the gate-fixtures/intent notes (lines ~150152): the bash path gate now emits access-path on path (not path-values); the makeHandler adapter and makePathDispatchResolver descriptions; and any "path matches lexical only" framing.
  • README.md — add that a path deny now also resists symlink-alias evasion (lines ~20 / ~71 describe path denies for sensitive files).

Test Impact Analysis

  1. New tests the change enables:
    • AccessPath.forPath with an explicit resolveBase (cd-folded base) and AccessPath.forLiteral (literal-only, empty boundary) — unit-testable directly.
    • The path tool gate denying a symlink whose canonical target matches a path deny pattern.
    • The bash-path gate matching a path rule against a symlinked token's canonical form.
  2. Tests that become redundant: none removed; existing lexical-match assertions stay valid (lexical aliases are still in matchValues()).
  3. Tests that must stay as-is: the #393 unknown-base test in test/access-intent/bash/program.test.ts (non-literal cd → literal only) — it now pins forLiteral behavior and must keep asserting no canonical/absolute leakage.

Existing tests to migrate (interface/shape changes, same package, type-level breaks):

  • test/access-intent/access-path.test.tsforExternalDirectoryforPath; add forLiteral cases.
  • test/permission-resolver.test.ts, test/handlers/gates/external-directory-policy.test.tsforExternalDirectoryforPath.
  • test/access-intent/bash/program.test.tspathRuleCandidates() shape (policyValuespath: AccessPath); assert via path.matchValues() / path.value().
  • test/handlers/gates/bash-path* and any path.ts gate tests — assert the emitted access-path intent and path.value()-derived approval base.

Invariants at risk

This change touches surfaces Phase 6 refactored. Documented invariants and their pinning tests:

  • #418 external-directory matches lexical canonical — preserved by the forPath rename (behavior-identical when resolveBase defaults to cwd). Pinned by test/handlers/gates/external-directory-policy.test.ts and test/access-intent/access-path.test.ts.
  • #393 unknown-base bash token keeps literal only — preserved by routing that case through forLiteral. Pinned by the non-literal-cd case in test/access-intent/bash/program.test.ts (extend it to assert matchValues() carries no canonical/absolute).
  • #382 canonical is win32-lowercasedforPath uses canonicalNormalizePathForComparison (unchanged). Pinned by access-path.test.ts.
  • #478 single resolve(intent) entry point — unchanged; both producers still emit one intent through resolve.

TDD Order

  1. feat(pi-permission-system): add AccessPath.forPath and forLiteral factories Test surface: test/access-intent/access-path.test.ts. Add forPath(pathValue, { cwd, resolveBase }) (generalized rename of forExternalDirectory) and forLiteral(literal); migrate the existing forExternalDirectory tests to forPath and update the three production call sites in the same commit (external-directory.ts, cwd-projection.ts ×2) — removing an export breaks all importers at the type level, so fold them together. Cover forLiteral (matchValues [literal], empty boundary) and forPath with an explicit resolveBase. Also update test/permission-resolver.test.ts and test/handlers/gates/external-directory-policy.test.ts (rename) in this commit.

  2. feat(pi-permission-system)!: match the canonical form on the path tool gate Test surface: the path.ts gate tests. Migrate path.ts to build AccessPath.forPath and emit access-path on path; derive the approval pattern from value(). Red: a tool reading a symlink whose canonical target matches a path deny is now denied. Breaking — feat!: with BREAKING CHANGE: footer.

  3. feat(pi-permission-system)!: match the canonical form on the bash-path gate Test surface: test/access-intent/bash/program.test.ts + bash-path gate tests. Change BashPathRuleCandidate to { token, path: AccessPath }, rebuild candidates in projectRuleCandidates (inline/remove getPolicyValuesForRuleCandidate, preserve the forLiteral unknown-base branch), and migrate bash-path.ts to emit access-path and derive the approval base from path.value(). The projectRuleCandidates return-type change and its bash-path.ts consumer + tests break together — one commit. Red: a bash token symlinked to a path-denied target is denied; the #393 unknown-base case still yields literal-only matches. Breaking — feat!:.

  4. refactor(pi-permission-system): drop the unproduced path-values emitted variant Test surface: type-level + fixtures. Remove PathValuesAccessIntent from the emitted AccessIntent union (keep in ResolvedAccessIntent); simplify toResolvedIntent; update gate-fixtures.ts (makePathDispatchResolver, makeHandler adapter) to the tool | access-path emitted surface. tsc confirms no remaining emitter.

  5. docs(pi-permission-system): document canonical path-surface matching Update docs/configuration.md, docs/architecture/architecture.md, .pi/skills/package-pi-permission-system/SKILL.md, and README.md per Module-Level Changes. No release impact on its own (rides the breaking feat).

Risks and Mitigations

  • Risk: the rename silently changes external-directory behavior. Mitigation: forPath(p, { cwd }) is behavior-identical (proved above); the external-directory-policy and access-path tests pin it and run unchanged-in-intent.
  • Risk: the #393 unknown-base case regresses to over-matching (spurious canonical/absolute). Mitigation: route it through forLiteral; extend the existing program test to assert matchValues() carries only the literal.
  • Risk: dedup or session-approval keys shift, invalidating in-flight approvals. Mitigation: keys derive from value() (lexical), which is unchanged; covered by existing approval/dedup tests.
  • Risk: an existing user config's path rule starts matching a previously-unmatched symlinked path on upgrade. This is the intended breaking behavior; mitigation is the BREAKING CHANGE: note and the docs update describing the new symlink-resistant matching.

Open Questions

  • None blocking. The residual #487 scope (config-pattern and prompt-input AccessPath migration) is unaffected; this plan narrows it by completing the bash-path migration and the path-values collapse it listed. No new follow-up issue is filed (no new work is deferred — work is pulled forward).