fix(tool): check write permissions against symlink-resolved paths - #621
Open
blueberrycongee wants to merge 1 commit into
Open
blueberrycongee wants to merge 1 commit into
blueberrycongee wants to merge 1 commit into
Conversation
write_file and edit_file only checked the literal path the caller supplied, while the OS follows symlinks when writing. A workspace symlink such as `repo-internals -> .git` let writes bypass the default .git/node_modules/dist deny list, and a symlink to a directory outside the workspace let writes skip the outside-workspace permission prompt. Resolve the real write target (including not-yet-existing files and dangling symlinks) and apply the deny list and workspace boundary to it as well. Workspace-scoped write_file/edit_file allow rules also no longer match paths that resolve outside the workspace. Co-Authored-By: Claude Opus 5.5 <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.
Summary
write_file/edit_filechecked write permission against the literal path, while the OS follows symlinks when it writes. The symlink-resolved check inresolvePilotDeckWorkspacePathonly ran in themustExistbranch, which these tools don't use. So a symlink inside the workspace could send a write somewhere the checks never looked at:repo-internals -> .git:write_file .git/HEADis denied, butwrite_file repo-internals/HEADpassed and overwrote.git/HEAD(after that,git symbolic-ref HEADexits 128). This also worked inbypassPermissionsmode, which is still supposed to enforce the.git/node_modules/distdeny list.escape -> /some/outside/dir:checkFilesystemWritePermissionreturnedpassthroughinstead ofask, so the write left the workspace with no prompt.head-link -> .git/HEAD), a dangling file symlink (config-link -> .git/config), andedit_file.Changes
pathSafety.ts: addresolveRealWritePath. It finds where a write will actually land by resolving the deepest existing ancestor and then following any dangling symlinks, so it works for files that don't exist yet. For writes, the deny list is now checked against both the literal path and this resolved path. If a literal in-workspace path resolves outside every workspace root, the check now reports it as outside the workspace. That meanscheckFilesystemWritePermissionreturnsaskinstead ofpassthrough, andexecutewithout an allow decision rejects the write.matchPermissionRule.ts: an allow rule forwrite_file/edit_filewith no pattern only covers in-workspace paths. It now also requires the symlink-resolved path to be inside the workspace, so a symlink escape can't slip through an existing rule without a prompt.absolutePath/relativePathare unchanged, so read-before-write snapshots and diffs behave the same.Remaining limitation: this is a check-then-write sequence. A symlink swapped in between the check and the write (TOCTOU) is not covered; that would need OS-level sandboxing.
Validation
tests/tool/write-symlink-escape.spec.ts(8 cases). Onupstream/mainwithout the fix, 6 of the 7 tool-level cases fail. The one that passes is the in-workspace symlink control case. With the fix, all 8 pass.upstream/main(e76ae087) worktree: 655 passed, 0 failed, 0 cancelled, 2 skipped (657 total).tsc+ the plugin copy step directly, becausenpm run build's runtime guard requires Node 22 and no Node 22 was available locally. No native-module tests were affected in either run.🤖 Generated with Claude Code