mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
220 lines
15 KiB
Markdown
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
|