chore(hardening): re-check file-tool paths against their symlink target - #4
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Path canonicalization for the file tools is lexical — the module says so itself (
path-access.ts: "Canonicalization is lexical only (norealpath/ symlink following)"). The enforcement layer is not:IHostFileSystemusesreadFile/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:
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.resolveWithinresolves the existing prefix and re-checks against realpath'd roots. The file tools simply don't go through that domain.What changed
New
assertRealPathAccessintool/path-access.ts:additionalDirsincluded, so skill roots keep working).absolute-outside-allowedis existing policy and those stay the approval layer's call. This PR only tightens; it does not newly restrict anything that was legitimately reachable.path-accessstays 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, whilerealpathis async through the host FS abstraction. Making the decision itself symlink-aware would mean turningresolveExecutionasync 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
additionalDirshonoured; explicitly-outside path left alone.edit.test.tsconstructsEditTooldirectly and needed the new constructor argument.agent-core-v2suite green: 310 files / 4879 tests.oxlintandtsc --noEmitclean.Checklist
minor).