Skip to content

chore(hardening): re-check file-tool paths against their symlink target - #4

Merged
devGregA merged 1 commit into
mainfrom
hardening/symlink-containment
Aug 13, 2026
Merged

chore(hardening): re-check file-tool paths against their symlink target#4
devGregA merged 1 commit into
mainfrom
hardening/symlink-containment

Conversation

@devGregA

Copy link
Copy Markdown
Contributor

Problem

Path canonicalization for the file tools is lexical — the module says so itself (path-access.ts: "Canonicalization is lexical only (no realpath / symlink following)"). The enforcement layer is not: IHostFileSystem uses readFile / writeFile / appendFile, which the OS resolves through symlinks at open time.

So a symlink that sits inside the workspace reads as inside the workspace to every check, while the write or read actually lands on its target. Two consequences:

  • an in-workspace path can resolve to a file outside the workspace;
  • a link with an innocuous name (notes.md) can resolve to a sensitive file, which the basename-driven sensitive check never sees.

Note the same containment already exists elsewhere in the codebase — workspaceFs/fsService.resolveWithin resolves the existing prefix and re-checks against realpath'd roots. The file tools simply don't go through that domain.

What changed

New assertRealPathAccess in tool/path-access.ts:

  • Resolves the longest existing prefix of the path and re-attaches the remaining tail, so a write to a not-yet-created file still gets its parent directory resolved — that's where a redirect would sit.
  • Requires that a path which looked in-workspace is still in-workspace after symlinks resolve, checked against realpath'd workspace roots (additionalDirs included, so skill roots keep working).
  • Re-checks sensitivity against the resolved target, catching the innocuous-name case.
  • Deliberately does not touch paths the caller already gave as outside the workspace — absolute-outside-allowed is existing policy and those stay the approval layer's call. This PR only tightens; it does not newly restrict anything that was legitimately reachable.
  • Takes the resolver as a parameter, so path-access stays free of a filesystem dependency and remains unit-testable.

Wired into Read, Write and Edit at execution time, where the call is already async — no change to the tool contract.

Why at execution time

The approval decision is made in resolveExecution, which is synchronous, while realpath is async through the host FS abstraction. Making the decision itself symlink-aware would mean turning resolveExecution async across every tool — a contract change well beyond a hardening patch. Checking at execution closes the escape (the read/write is refused), at the cost that the approval prompt still displays the lexical path. That trade is deliberate; the contract change is worth discussing separately.

Performance

Nothing changes on the common path: when no component is a symlink, the resolved path equals the canonical one and the guard returns before doing any further work.

Testing

  • 7 hermetic unit tests with an injected resolver: escape blocked; sensitive target blocked; parent-directory resolution for a file that doesn't exist yet; in-workspace symlink allowed; additionalDirs honoured; explicitly-outside path left alone.
  • 1 real-filesystem test that creates an actual symlink out of a temp workspace and asserts it is refused, with a genuine in-workspace file as the control.
  • Verified as real tests: with the guard reverted, all 8 fail.
  • One test updated: edit.test.ts constructs EditTool directly and needed the new constructor argument.
  • Full agent-core-v2 suite green: 310 files / 4879 tests.
  • oxlint and tsc --noEmit clean.

Checklist

  • Problem explained above (fork-local hardening; no upstream issue).
  • Tests added that prove the change works.
  • Changeset added (minor).
  • No doc update needed.

Path canonicalization is lexical, so a symlink inside the workspace reads
as inside it while the OS follows the link at open time. That gap lets an
in-workspace path resolve to a file outside the workspace, or to a
sensitive file under an innocuous name.

Add assertRealPathAccess: resolve the longest existing prefix of the path
(so a not-yet-created file still gets its parent directory resolved), then
require that a path which looked in-workspace is still in-workspace, and
that the resolved target is not sensitive. Paths the caller already gave
as outside the workspace are left to the approval layer. The helper takes
the resolver as a parameter, keeping the path-access module free of a
filesystem dependency.

Wire it into Read, Write and Edit at execution time, where the call is
already async. Nothing on the common path changes: when no component is a
symlink the resolved path equals the canonical one and the guard returns
immediately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@devGregA
devGregA merged commit 2958008 into main Aug 13, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant