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:
- Escape (security-relevant): an in-cwd symlink pointing outside cwd is treated as internal.
A
./link -> /etcsymlink letscat ./link/hostsread/etc/hostswith no prompt, because./link/hostsnormalizes lexically to<cwd>/link/hosts, which passes the within-cwd check. - False prompt (nuisance): a symlinked working directory flags its own paths as external.
On macOS
/tmpis a symlink to/private/tmpandcwdresolves to/private/tmp, so a/tmp/footoken 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.realpathSyncthrowsENOENT, 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 -> .envsymlink evading a*.envdeny) 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
$HOMEexpansion work — issue #350 already added$HOMEtonormalizePathForComparison. - 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—describeExternalDirectoryGatecallsisPathOutsideWorkingDirectoryfor the gate condition, then computesnormalizedExtPath = 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 byisPathWithinDirectory; returns the surviving normalized paths (used for the prompt, the approval patterns, and the resolver checks indescribeBashExternalDirectoryGate).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_directoryask/denyis the dangerous direction (symptom 1). - The
code-designskill prefers pure functions with IO at the edges. We localize therealpathSynccall in a single small module so the rest ofpath-utilsstays lexical, and tests mocknode:fs(the same techniquepath-utils.test.tsalready uses fornode:os) rather than threading arealpathdependency through the pipeline. (User-confirmed: directfs.realpathSyncover DI threading; scope covers both bash and tool-call surfaces.) node:*mocks must include adefaultkey 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
realpathSyncin one call. - A non-existent leaf (a
writetarget) walks up one level, canonicalizes the existing parent, and re-appends the leaf. - A path with no existing ancestor (e.g. the synthetic
/test/projectused 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/EACCESfall 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); directnode:fsrealpathSyncimport.src/path-utils.ts— addcanonicalNormalizePathForComparison; switchisPathOutsideWorkingDirectoryto it. ImportcanonicalizePath.src/handlers/gates/external-directory.ts— computenormalizedExtPathviacanonicalNormalizePathForComparison(replacing the lexical call).src/handlers/gates/bash-program.ts— canonicalizenormalizedCwdand each candidatenormalizedinexternalPathsbefore the containment filter.docs/architecture/architecture.md— addcanonicalize-path.tsto the source-tree listing and extend thepath-utils.tsdescription 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
- New lower-level tests the change enables:
canonicalize-path.test.tsunit-tests the walk-up algorithm in isolation (existing symlink, non-existent leaf, deeply non-existent, root fallback,ELOOP/EACCESfallback, empty input) with a mockednode:fsrealpathSync. This is now possible because the FS effect is isolated in one tiny module. - Redundant tests: none.
Existing
isPathOutsideWorkingDirectoryandexternalPathscases assert containment, not canonicalization; they stay as behavioral guards (and pass with an identityrealpathSyncmock). - Tests that must stay as-is:
external-directory-integration.test.tsandexternal-directory-session-dedup.test.tsuse 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
test:+feat:—canonicalize-path.ts+test/canonicalize-path.test.ts. Red:vi.mock("node:fs")(withdefaultkey) supplying a map-basedrealpathSync; cover existing-symlink resolution, non-existent-leaf re-append, deep walk-up, root-levelENOENT→ lexical fallback,ELOOP/EACCES→ lexical fallback, and empty-string input. Green: implementcanonicalizePath. Commit:feat(pi-permission-system): add best-effort canonicalizePath helper.test:+fix:— tool-call containment. Addvi.mock("node:fs")totest/path-utils.test.tswith an identityrealpathSyncdefault (so all existing cases pass) plus per-test symlink mappings. Red: in-cwd symlink to/etc→isPathOutsideWorkingDirectorytrue (symptom 1); path under a symlinked cwd → false (symptom 2). Updatetest/handlers/gates/external-directory.test.tsfor the now-canonicalnormalizedExtPathin the descriptor'sinput,sessionApprovalpattern, and infra-read path (fold into this commit — same surface). Green: addcanonicalNormalizePathForComparison, switchisPathOutsideWorkingDirectory, and updatedescribeExternalDirectoryGate. Runpnpm run check(shared-function change). Commit:fix(pi-permission-system): canonicalize tool-call external-directory containment (#345).test:+fix:— bash containment. Addvi.mock("node:fs")totest/handlers/gates/bash-program.test.tswith 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/tmpcwd is not flagged (symptom 2). Green: canonicalize cwd and candidates inBashProgram.externalPaths. Verifytest/handlers/gates/bash-external-directory.test.tsstill 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).docs:— updatedocs/architecture/architecture.mdsource-tree listing andpath-utils.tsdescription. CheckREADME.md/docs/configuration.mdfor 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
realpathSyncsyscalls 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:
realpathSyncreturns canonical case on win32;canonicalNormalizePathForComparisonre-lowercases. All unit tests mocknode: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.