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

220 lines
15 KiB
Markdown

---
issue: 345
issue_title: "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 (`normalizeInput``evaluate`, 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.ts``normalizePathForComparison(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.ts``describeExternalDirectoryGate` 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.ts``BashProgram.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:
```typescript
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):
```typescript
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:
```typescript
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 `/etc``isPathOutsideWorkingDirectory` 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.
[#350]: https://github.com/gotgenes/pi-packages/issues/350