Files
my-pi/pi-permission-system/docs/plans/0345-canonicalize-path-containment.md

15 KiB

issue, issue_title
issue issue_title
345 external_directory gate uses lexical path normalization (no symlink resolution) — in-cwd symlink escapes the cwd boundary

Canonicalize paths before the external-directory containment check

Problem Statement

Containment in the external_directory gate is decided lexically (path.resolve + path.normalize) with no symlink canonicalization, so the gate disagrees with what the shell does at exec time. One root cause, two symptoms:

  1. Escape (security-relevant): an in-cwd symlink pointing outside cwd is treated as internal. A ./link -> /etc symlink lets cat ./link/hosts read /etc/hosts with no prompt, because ./link/hosts normalizes lexically to <cwd>/link/hosts, which passes the within-cwd check.
  2. False prompt (nuisance): a symlinked working directory flags its own paths as external. On macOS /tmp is a symlink to /private/tmp and cwd resolves to /private/tmp, so a /tmp/foo token is flagged as outside cwd and prompts (or, headless, blocks).

Both reported repros run through bash (BashProgram.externalPaths), but the tool-call surface (read/write/edit/find/grep/ls via isPathOutsideWorkingDirectory) carries the identical lexical flaw — a read of ./link/hosts escapes the same way.

The fix is to decide containment on canonical (symlink-resolved) paths so that a path resolving outside cwd via an in-cwd symlink is recognized as external (1), and a path under a symlinked cwd is recognized as internal (2).

Goals

  • Resolve symlinks (best-effort) on both the candidate path and cwd before the within-directory comparison, for the tool-call surface (isPathOutsideWorkingDirectory) and the bash surface (BashProgram.externalPaths).
  • Close the in-cwd-symlink escape (symptom 1) so the gate fires on the real target.
  • Stop flagging paths under a symlinked cwd as external (symptom 2).
  • Handle non-existent write targets: fs.realpathSync throws ENOENT, so resolve the longest existing ancestor and re-append the non-existent tail.
  • Degrade gracefully: any path that cannot be canonicalized (missing root, permission error, symlink loop) falls back to the current lexical behavior, so non-symlink paths are unaffected.

Non-Goals

  • The optional, separate path-pattern deny-evasion surface (a notes -> .env symlink evading a *.env deny) is out of scope. It is a different code path (normalizeInputevaluate, not the containment check) and the issue marks it optional.
  • No canonicalization of skill-read / skill-prompt-sanitizer path matching. Those match skill file locations for prompt filtering, not a security boundary, and adding a filesystem hit there is unwarranted.
  • No $HOME expansion work — issue #350 already added $HOME to normalizePathForComparison.
  • No new config fields, schema entries, or surfaces.
  • No change to cwd-resolution or pattern-matching semantics outside the containment decision.

Background

Relevant modules:

  • src/path-utils.tsnormalizePathForComparison(pathValue, cwd) trims, strips a leading @, home-expands (~, $HOME), resolves against cwd, normalizes, and lowercases on win32. isPathWithinDirectory(path, dir) is a pure string prefix check. isPathOutsideWorkingDirectory(pathValue, cwd) normalizes both sides lexically and returns !within. grep -rn realpath src/ is empty today.
  • src/handlers/gates/external-directory.tsdescribeExternalDirectoryGate calls isPathOutsideWorkingDirectory for the gate condition, then computes normalizedExtPath = normalizePathForComparison(...) used for the infrastructure-read check, the session-approval pattern, and the resolver input.
  • src/handlers/gates/bash-program.tsBashProgram.externalPaths(cwd) normalizes cwd and each cd-aware candidate lexically, then filters by isPathWithinDirectory; returns the surviving normalized paths (used for the prompt, the approval patterns, and the resolver checks in describeBashExternalDirectoryGate).
  • src/handlers/gates/tool-call-gate-pipeline.ts — assembles and runs the gate producers; constructed once in the composition root.

Constraints from AGENTS.md / skills:

  • Default to least privilege; under-matching a containment check on an external_directory ask/deny is the dangerous direction (symptom 1).
  • The code-design skill prefers pure functions with IO at the edges. We localize the realpathSync call in a single small module so the rest of path-utils stays lexical, and tests mock node:fs (the same technique path-utils.test.ts already uses for node:os) rather than threading a realpath dependency through the pipeline. (User-confirmed: direct fs.realpathSync over DI threading; scope covers both bash and tool-call surfaces.)
  • node:* mocks must include a default key mirroring named exports (testing skill).

Design Overview

Best-effort canonicalization

A new module src/canonicalize-path.ts resolves symlinks for an already-absolute path, tolerating non-existent tails:

import { realpathSync } from "node:fs";
import { basename, dirname, join } from "node:path";

/**
 * Resolve symlinks in an absolute path, best-effort.
 *
 * Walks up to the longest existing ancestor, canonicalizes it, and re-appends
 * the non-existent tail. Returns the input unchanged when it cannot be
 * canonicalized (no existing ancestor, permission error, or symlink loop),
 * so callers fall back to lexical containment for non-symlink paths.
 */
export function canonicalizePath(absolutePath: string): string {
  if (!absolutePath) return absolutePath;

  const tail: string[] = [];
  let current = absolutePath;
  while (true) {
    try {
      const real = realpathSync(current);
      return tail.length === 0 ? real : join(real, ...tail.toReversed());
    } catch (error) {
      const code = (error as NodeJS.ErrnoException).code;
      if (code !== "ENOENT" && code !== "ENOTDIR") {
        return absolutePath; // EACCES, ELOOP, … → lexical fallback
      }
      const parent = dirname(current);
      if (parent === current) return absolutePath; // reached root, still missing
      tail.push(basename(current));
      current = parent;
    }
  }
}

Properties:

  • An existing symlink anywhere in the path is resolved by realpathSync in one call.
  • A non-existent leaf (a write target) walks up one level, canonicalizes the existing parent, and re-appends the leaf.
  • A path with no existing ancestor (e.g. the synthetic /test/project used in integration tests) walks to root and returns the lexical input unchanged — so existing tests that use non-existent paths keep their current behavior with no mocking.
  • ELOOP / EACCES fall back to lexical rather than throwing.

Canonical containment in path-utils

Add a canonicalizing variant alongside the lexical normalizer (keeping normalizePathForComparison lexical for skill matching):

export function canonicalNormalizePathForComparison(
  pathValue: string,
  cwd: string,
): string {
  const lexical = normalizePathForComparison(pathValue, cwd);
  if (!lexical) return "";
  const canonical = canonicalizePath(lexical);
  return process.platform === "win32" ? canonical.toLowerCase() : canonical;
}

isPathOutsideWorkingDirectory switches both sides to the canonical variant:

export function isPathOutsideWorkingDirectory(
  pathValue: string,
  cwd: string,
): boolean {
  const normalizedCwd = canonicalNormalizePathForComparison(cwd, cwd);
  const normalizedPath = canonicalNormalizePathForComparison(pathValue, cwd);
  if (!normalizedCwd || !normalizedPath) return false;
  if (isSafeSystemPath(normalizedPath)) return false;
  return !isPathWithinDirectory(normalizedPath, normalizedCwd);
}

isSafeSystemPath still runs on the canonical path; /dev/null etc. canonicalize to themselves, so the device-file allowlist is unaffected.

Gate coherence

describeExternalDirectoryGate recomputes its normalizedExtPath via canonicalNormalizePathForComparison so the gate's fire decision, the infrastructure-read check, the derived approval pattern, and the resolver input all reference the same canonical target. The raw externalDirectoryPath is still used for the user-facing display message, so the prompt continues to echo what the user wrote (e.g. ./link/hosts), while the approval pattern now covers the real target (/etc/hosts).

BashProgram.externalPaths(cwd) canonicalizes the normalized cwd once and each normalized candidate before the within-directory filter and dedup seen set, and returns the canonical paths. Downstream (describeBashExternalDirectoryGate) keeps deriving patterns and resolving against those values, now canonical.

Worked outcomes

Repro Lexical (today) Canonical (fixed)
cat ./link/hosts, ./link -> /etc <cwd>/link/hosts → inside → no gate /etc/hosts → outside → gate fires
read of ./link/hosts <cwd>/link/hosts → inside → no gate /etc/hosts → outside → gate fires
/tmp/foo under cwd /private/tmp token /tmp/foo vs /private/tmp → outside → prompt both canonicalize under /private/tmp → inside → no prompt
non-existent /test/project/x (tests) lexical walk-to-root fallback → lexical (unchanged)

Module-Level Changes

  • src/canonicalize-path.ts (new) — canonicalizePath(absolutePath); direct node:fs realpathSync import.
  • src/path-utils.ts — add canonicalNormalizePathForComparison; switch isPathOutsideWorkingDirectory to it. Import canonicalizePath.
  • src/handlers/gates/external-directory.ts — compute normalizedExtPath via canonicalNormalizePathForComparison (replacing the lexical call).
  • src/handlers/gates/bash-program.ts — canonicalize normalizedCwd and each candidate normalized in externalPaths before the containment filter.
  • docs/architecture/architecture.md — add canonicalize-path.ts to the source-tree listing and extend the path-utils.ts description to mention symlink canonicalization for containment.

No exports are removed or renamed, so no consumer-import sweep is needed; the only existing call sites of isPathOutsideWorkingDirectory and externalPaths keep their signatures.

Test Impact Analysis

  1. New lower-level tests the change enables: canonicalize-path.test.ts unit-tests the walk-up algorithm in isolation (existing symlink, non-existent leaf, deeply non-existent, root fallback, ELOOP/EACCES fallback, empty input) with a mocked node:fs realpathSync. This is now possible because the FS effect is isolated in one tiny module.
  2. Redundant tests: none. Existing isPathOutsideWorkingDirectory and externalPaths cases assert containment, not canonicalization; they stay as behavioral guards (and pass with an identity realpathSync mock).
  3. Tests that must stay as-is: external-directory-integration.test.ts and external-directory-session-dedup.test.ts use synthetic non-existent paths (/test/project, /outside/...); the walk-to-root fallback returns them unchanged, so they exercise the unchanged lexical path with no mock and need no edits.

TDD Order

  1. test: + feat:canonicalize-path.ts + test/canonicalize-path.test.ts. Red: vi.mock("node:fs") (with default key) supplying a map-based realpathSync; cover existing-symlink resolution, non-existent-leaf re-append, deep walk-up, root-level ENOENT → lexical fallback, ELOOP/EACCES → lexical fallback, and empty-string input. Green: implement canonicalizePath. Commit: feat(pi-permission-system): add best-effort canonicalizePath helper.
  2. test: + fix: — tool-call containment. Add vi.mock("node:fs") to test/path-utils.test.ts with an identity realpathSync default (so all existing cases pass) plus per-test symlink mappings. Red: in-cwd symlink to /etcisPathOutsideWorkingDirectory true (symptom 1); path under a symlinked cwd → false (symptom 2). Update test/handlers/gates/external-directory.test.ts for the now-canonical normalizedExtPath in the descriptor's input, sessionApproval pattern, and infra-read path (fold into this commit — same surface). Green: add canonicalNormalizePathForComparison, switch isPathOutsideWorkingDirectory, and update describeExternalDirectoryGate. Run pnpm run check (shared-function change). Commit: fix(pi-permission-system): canonicalize tool-call external-directory containment (#345).
  3. test: + fix: — bash containment. Add vi.mock("node:fs") to test/handlers/gates/bash-program.test.ts with an identity default. Red: an in-cwd symlink token resolves to its external target and is flagged (symptom 1); a /tmp/... token under a symlinked /private/tmp cwd is not flagged (symptom 2). Green: canonicalize cwd and candidates in BashProgram.externalPaths. Verify test/handlers/gates/bash-external-directory.test.ts still passes (identity mock keeps non-symlink fixtures stable); adjust only if a fixture path happens to canonicalize differently. Commit: fix(pi-permission-system): canonicalize bash external-path containment (#345).
  4. docs: — update docs/architecture/architecture.md source-tree listing and path-utils.ts description. Check README.md / docs/configuration.md for any claim that external-directory matching is purely lexical; update if present. Commit: docs(pi-permission-system): note symlink canonicalization in architecture.

Run the full suite (pnpm --filter @gotgenes/pi-permission-system exec vitest run) after step 3, since steps 2 and 3 change shared helpers consumed across the gate suite.

Risks and Mitigations

  • TOCTOU: canonicalization is inherently best-effort — a symlink can change between the check and exec. This narrows the gap dramatically versus today (no resolution at all) but does not close it; documented as accepted.
  • Performance: one or more realpathSync syscalls per path check. The walk-up is bounded by path depth and only runs on the gate path (not hot); acceptable.
  • Visible behavior change: prompts/patterns for symlinked inputs now reference the canonical target. This is an improvement (the approval covers the real destination); the user-facing display still echoes the raw input via externalDirectoryPath.
  • Cross-platform: realpathSync returns canonical case on win32; canonicalNormalizePathForComparison re-lowercases. All unit tests mock node:fs, so they are deterministic regardless of host platform.
  • Existing tests with synthetic paths: the root-fallback property keeps them on the lexical path; verified by the integration-test note above.

Open Questions

  • The optional path-pattern deny-evasion surface (symlink alias vs *.env) is deferred; file a follow-up if it warrants its own gate-level fix.
  • Whether to canonicalize skill-read matching is deferred until there is a concrete skill-path symlink case.