fix(sandbox): remove a symlink itself in UnixLocal rm, not its target - #4830
fix(sandbox): remove a symlink itself in UnixLocal rm, not its target#4830coderdailyone wants to merge 10 commits into
Conversation
UnixLocalSandboxSession.rm() resolved every symlink before removing, so `rm` on a symlink deleted the link target and left the link dangling: a file symlink unlinked the real file, and a directory symlink with recursive=True rmtree'd the real directory. Symlinks that pointed outside the workspace or nowhere could not be removed at all, because the resolved target failed the workspace check. POSIX `rm`, the exec-backed session implementations (`rm -rf -- path`), and the documented intent of _validate_remote_path_access all remove the link itself. Validate the entry's parent directory (following symlinks) and keep the leaf name unresolved, for both the direct and the user-scoped rm paths. Entries reached through an escaping symlinked parent are still rejected. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BN4v25msJgrjNgac97g1Az
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6ff8a29e3d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Run the raw input through the workspace path policy (without following symlinks) before separating the unresolved leaf name, so a Windows drive-absolute string such as `C:\outside\file` is still rejected with InvalidManifestPathError on Unix instead of being treated as a literal entry name under the workspace root. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BN4v25msJgrjNgac97g1Az
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e505c43354
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Add `follow_leaf_symlink` to WorkspacePathPolicy.normalize_path(): with resolve_symlinks=True it resolves the parent directories and keeps the final component as the entry itself, then applies the workspace root, the longest matching extra grant and the read-only check to that location. UnixLocalSandboxSession._rm_target_path() now uses it instead of a lexical precheck plus a hand-built parent/leaf split, which missed a more specific read-only grant under a writable one and rejected absolute paths resolved through a symlinked Manifest.root. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BN4v25msJgrjNgac97g1Az
sylvesterkaczmarek
left a comment
There was a problem hiding this comment.
Keeping the leaf unresolved seems to reintroduce a path-based TOCTOU. After _rm_target_path() resolves and authorizes the parent, a background process can rename that parent and replace it with a symlink before unlink()/rmtree() runs; the host-side delete then follows the new parent outside the validated tree. Could this pin the validated parent with a dir fd and remove relative to that inode, rather than acting on the reconstructed pathname?
|
Thanks for looking at this. I agree there is a rename race between authorizing the parent and the Closing that class properly means pinning the validated directory (open with So I'd prefer to keep this PR to the symlink-target bug and treat fd-pinned removal as a separate, cross-operation change. Happy to write that follow-up if maintainers want it. |
… the leaf-preserving check Keep the same WorkspacePathPolicy behavior as openai#4833: with follow_leaf_symlink=False the root itself (relative "." or the configured root alias) is resolved fully so a symlinked Manifest.root stays authorized, while entries below it keep their leaf identity. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFSMrLZZ3oq1dKmJFAofz6
There was a problem hiding this comment.
💡 Codex Review
When a path-only writable extra_path_grant is itself a symlink, such as /tmp/shared-link -> /srv/shared, calling rm("/tmp/shared-link") now preserves the candidate as /tmp/shared-link but compares it only against the resolved grant root /srv/shared. _matching_grant() therefore returns None and raises InvalidManifestPathError, even though grants explicitly apply to SDK file APIs and the intended leaf-preserving behavior should unlink this writable grant entry rather than reject it; authorize the configured grant entry itself while retaining resolved-root matching for descendants.
AGENTS.md reference: AGENTS.md:L205-L205
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
… alias Compare the requested path against the lexically normalized configured root, so a Manifest.root such as /tmp/dummy/../ws-link is still treated as the root in the leaf-preserving check. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFSMrLZZ3oq1dKmJFAofz6
seratch
left a comment
There was a problem hiding this comment.
The ordinary symlink cases are fixed. The existing sticky-directory gap is newly reachable for an external-target symlink that the base rejected: user= checks only parent write/execute access, then the SDK identity unlinks an entry the requested user may not own. Please enforce the requested user's removal permission on this newly accepted leaf path and add a controlled non-owner case.
…m probe The user-scoped rm access probe checked only that the requested user may write and search the parent directory. In a sticky directory such as /tmp, POSIX additionally requires the user to own the entry (or the directory), so a non-owner could have the SDK process unlink an entry the user could not remove, including the external-target symlinks that this change now accepts. Enforce that ownership rule on the leaf entry itself (a symlink is judged by the link's owner), and exercise the probe with a real shell, including a non-owner case in a sticky directory owned by another user. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFSMrLZZ3oq1dKmJFAofz6
|
Thanks for the review. Addressed in |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4b3a17d057
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 'if [ -k "$parent" ] && [ ! -O "$parent" ]; then\n' | ||
| ' [ -n "$(find "$target" -maxdepth 0 -user "$(id -un)" 2>/dev/null)" ]\n' |
There was a problem hiding this comment.
Allow privileged users through the sticky-directory probe
When rm(..., user="root") targets another user's entry in a sticky directory that root does not own, this branch rejects the operation solely because neither ownership test passes. On Linux, the exact probe returns 1 in that scenario while rm succeeds because root has CAP_FOWNER, so UnixLocalSandboxSession.rm() raises WorkspaceArchiveWriteError for a deletion that the requested identity is authorized to perform. Account for privileged-user semantics rather than treating ownership as exhaustive.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 001a24c6: the ownership rule is skipped when the probe runs as uid 0, since root (CAP_FOWNER) may unlink any entry it can reach. The sticky test now runs the real probe as an unprivileged user (subprocess.run(..., user=nobody)) for the non-owner refusals, and asserts that root is allowed to remove another user's file inside a sticky directory root does not own; that last assertion fails on the previous revision.
| if ( | ||
| follow_leaf_symlink | ||
| or absolute_path.name in ("", ".", "..") | ||
| or absolute_path == Path(self._normalized_root().as_posix()) |
There was a problem hiding this comment.
Canonicalize the configured root before comparing it
When Manifest.root is accepted as a noncanonical symlink spelling such as /tmp/dummy/../ws-link, passing that same root value to rm() misses this resolving branch because it is not lexically equal to the normalized /tmp/ws-link; the unresolved leaf is then outside the resolved /tmp/ws root and is rejected, although the previous full-resolution path and rm(".") accept it. Fresh evidence beyond the prior root-alias fix is that test_rm_validates_the_symlinked_root_alias_itself constructs exactly this noncanonical root but tests only "." and the canonical alias, not the configured string itself. Compare canonical root identities through the existing path-policy source of truth rather than lexical Path equality.
AGENTS.md reference: AGENTS.md:L95-L95
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 001a24c6: both sides of the root comparison now go through the policy's normalized root (posixpath.normpath of the absolute input against _normalized_root()), so the configured noncanonical spelling itself (/tmp/dummy/../ws-link) resolves as the root. test_rm_validates_the_symlinked_root_alias_itself now also passes that exact configured string and fails on the previous revision.
…root comparison The sticky-directory ownership rule does not bind root (CAP_FOWNER), so skip it when the probe runs as uid 0 instead of refusing a removal the requested identity may perform. Compare the requested path against the workspace root through the policy's normalized root on both sides, so the configured noncanonical spelling of a symlinked Manifest.root (/tmp/dummy/../ws-link) is recognized as the root too. The non-owner sticky case now runs the real probe as an unprivileged user. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFSMrLZZ3oq1dKmJFAofz6
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 001a24c6f5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| # entry itself, so a symlink is judged by the link's owner, not its target's. | ||
| # root (CAP_FOWNER) may unlink anything it can reach, so the ownership rule is skipped. | ||
| 'if [ "$(id -u)" != 0 ] && [ -k "$parent" ] && [ ! -O "$parent" ]; then\n' | ||
| ' [ -n "$(find "$target" -maxdepth 0 -user "$(id -un)" 2>/dev/null)" ]\n' |
There was a problem hiding this comment.
Use a portable ownership check on macOS
On the supported macOS UnixLocal backend (including the native-macos-sandbox workflow), /usr/bin/find does not support the GNU-only -maxdepth primary; it is absent from Apple's find(1) expression reference. Consequently, when rm(..., user=...) checks an entry owned by that non-root user in a sticky directory owned by someone else, find fails, its stderr is discarded, the substitution is empty, and the SDK raises WorkspaceArchiveWriteError even though the user may unlink the entry. Use a portable lstat/ownership probe or platform-specific syntax.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in e25457b2: the ownership probe now uses stat -c %u with a stat -f %u fallback (GNU and BSD/macOS spellings), both of which report the entry itself without -L, so a symlink is still judged by the link's owner and macOS find is no longer involved. The sticky tests (run as an unprivileged user and as root) pass unchanged.
…NU find macOS find has no -maxdepth; stat without -L (GNU -c / BSD -f) reports the entry itself on both platforms. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFSMrLZZ3oq1dKmJFAofz6
…d symlink, not its target WorkspaceEditor passed the symlink-resolved destination to rm(), so on UnixLocal a delete_file or move_to on a link deleted the real file and left the link dangling. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFSMrLZZ3oq1dKmJFAofz6
|
Added one more call site in The PR description's Fix section is updated accordingly. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4736a7a618
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| or PurePosixPath(posixpath.normpath(absolute_path.as_posix())) | ||
| == self._normalized_root() |
There was a problem hiding this comment.
Recognize symlink aliases for exact grant roots
When a writable extra_path_grants entry is itself a symlink, rm(str(grant.path), recursive=True) now preserves that leaf, but _matching_grant(..., resolve_roots=True) compares it only with the resolved grant target and raises InvalidManifestPathError; the old full-resolution path accepted the configured grant root, and paths below the alias remain accepted. Fresh evidence beyond the earlier workspace-root-alias fix is that this condition special-cases only _normalized_root(), with no equivalent canonical identity check for configured grants. Recognize an exact configured grant alias through the canonical grant policy before preserving its leaf.
AGENTS.md reference: AGENTS.md:L94-L95
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 4e3f519f: _is_configured_root_alias() now treats the configured spelling of every extra path grant the way it already treated the workspace root, so rm(str(grant.path), ...) on a grant that is itself a symlink resolves fully and is authorized against the grant's resolved target, while entries below it keep their leaf. test_rm_accepts_a_symlinked_grant_root_alias covers both a file below the alias and the alias itself.
| await self._session.rm(destination, user=self._user) | ||
| # Remove the workspace entry the model named, not the file it resolves to: | ||
| # `destination` follows symlinks, so a link would otherwise lose its target. | ||
| await self._session.rm(relative_path, user=self._user) |
There was a problem hiding this comment.
Reach rm before resolving delete-file symlinks
When delete_file names a dangling symlink or a symlink whose target is outside the workspace, this new rm(relative_path) call is never reached: the earlier normalize_path() follows the leaf and either rejects the outside target, while _ensure_exists() reads the resolved target and reports a dangling link as missing. Consequently, apply_patch still cannot delete those named workspace entries even though UnixLocalSandboxSession.rm() now explicitly supports both cases; validate existence without following the leaf for the delete branch.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 4e3f519f: the delete_file branch no longer goes through the leaf-following normalize_path() first. _ensure_entry_exists() reads the resolved target when the leaf resolves inside an allowed root, and otherwise (dangling link, or a link whose target is outside) confirms the named entry through ls() of its parent, then rm() receives the workspace-relative path. test_apply_patch_delete_file_removes_dangling_and_outward_symlinks (parametrized over missing.txt and /etc/hostname) and test_apply_patch_delete_file_reports_a_missing_entry cover it; the fake apply_patch session gained an ls() for the new existence check.
|
Thanks for the extra symlink and apply_patch coverage. One earlier blocker is still present on the current head: |
… delete dangling or outward links Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFSMrLZZ3oq1dKmJFAofz6
|
Thanks. My position from the earlier thread is unchanged: the resolve-then-operate-by-pathname window exists identically on |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4e3f519fe1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| else: | ||
| handle.close() | ||
| try: | ||
| handle = await self._session.read(destination, user=self._user) |
There was a problem hiding this comment.
Inspect the link entry before opening its target
When delete_file names a symlink to an in-workspace directory, normalization follows the leaf and this call attempts to open the target directory; UnixLocalSandboxSession.read() wraps the resulting IsADirectoryError as WorkspaceArchiveReadError, so the listing fallback never runs and the link remains. Check the named directory entry before reading its target, so apply_patch supports the same directory-symlink removal that the updated rm() path now supports.
AGENTS.md reference: AGENTS.md:L205-L205
Useful? React with 👍 / 👎.
|
That scope distinction makes sense. The resolve-then-operate pathname race is broader UnixLocal hardening that already exists across multiple operations, so I agree it should not remain a blocker specific to this symlink-leaf fix. I would track the inode-pinned hardening separately. The current directory-symlink delete case is a separate issue already raised inline on the latest head, so I will leave that thread to resolve rather than duplicate it here. |
Summary
This pull request fixes
UnixLocalSandboxSession.rm()so that removing a symlink removes the link itself instead of its target.Bug
UnixLocalSandboxSession.normalize_path()resolves every symlink (resolve_symlinks=True), andrm()operated on that resolved path. Against a realUnixLocalSandboxClientsession onmain(89c02c8), after the agent ranln -s plain.txt linkfile; ln -s realdir linkdir; ln -s /etc/hostname outside_file; ln -s /nonexistent danglingin the workspace:mainrm("linkfile")plain.txt;linkfileleft danglingrm("linkdir", recursive=True)shutil.rmtreeonrealdir/(all contents gone);linkdirleft danglingrm("dangling")InvalidManifestPathError: manifest path must not escape root— cannot be removedrm("outside_file")So a model asking to delete a link destroys the real data, and a link the model itself created (pointing outside or dangling) can never be cleaned up through the file API.
This is UnixLocal-specific. The exec-backed session implementation in
BaseSandboxSession.rm()runsrm -rf -- <path>, which removes the link, and the docstring of_validate_remote_path_access()already states the intended contract: "keeps safe leaf symlink operations working normally, such as removing a symlink instead of its target".Fix
UnixLocalSandboxSession._rm_target_path()validates the entry's parent directory with symlinks resolved (so containment and read-only-grant checks still apply), then keeps the leaf name unresolved. A leaf symlink is therefore unlinked as a symlink; regular files and directories behave exactly as before because their parent-resolved path equals the fully resolved path.BaseSandboxSession._check_rm_with_exec()is split so the sandbox-side access check (_check_rm_access_with_exec()) can run against an already-validated path. The user-scopedrm(..., user=...)path in UnixLocal now checks and removes the link entry rather than its target._check_rm_with_exec()keeps its signature and behavior for existing callers.WorkspaceEditor(theapply_patchtool) now passes the workspace-relative path torm()fordelete_fileand for the source of amove_to, instead of the symlink-resolved destination, so deleting or moving a link throughapply_patchremoves the link rather than its target on UnixLocal. Thedelete_fileexistence check reads the resolved target when it resolves inside an allowed root and otherwise confirms the named entry throughls()of its parent, so dangling and outward-pointing links can be deleted too. Exec-backed backends already removed the link and are unchanged.rmon the grant root alias stays authorized.rm("escape_dir/victim.txt")whereescape_dir -> /outside) are still rejected withInvalidManifestPathError.Behavior change
rmon a symlink inside a UnixLocal workspace now removes only the link, matching POSIXrm,rm -rf, and every other sandbox backend. No public signatures change._RM_ACCESS_CHECK_SCRIPT, run as the requested user viasudo -u) now also enforces POSIX sticky-directory ownership: in a sticky directory the user does not own (e.g./tmp), the entry itself must be owned by that user, judged withfind -maxdepth 0 -userso a symlink is checked by the link's owner rather than its target's. This closes the gap where the SDK process unlinked an entry the requested user could not remove, which the newly accepted external-target and dangling symlinks would otherwise have widened.Test plan
tests/sandbox/test_session_utils.py::test_rm_access_check_enforces_sticky_directory_ownershipruns the real probe script withshagainst a sticky directory owned by another uid (as root): another user's file and symlink are refused, our own file and symlink are allowed; it fails on the previous revision.test_rm_access_check_allows_own_entries_in_a_writable_directorycovers the ordinary directory, a dangling symlink entry, and the missing-path cases.New tests in
tests/sandbox/test_unix_local.py::TestUnixLocalRmSymlinkscover file symlink, directory symlink (recursive and not), symlink pointing outside the workspace, dangling symlink, the escaping-parent rejection, and the user-scoped path. Twoapply_patchtests (delete_file and move_to from a link) sit alongside them and fail without theapply_patch.pychange. Six of the sevenrmtests fail onmainand pass with this change.tests/sandbox(1453 passed), the full parallel suite (9261 passed; twotests/mcp/test_mcp_pagination_integration.pystdio tests failed under a memory-constrained 3-worker run and pass when rerun alone), serial tests,ruff format --check,ruff check, andmypy/pyrighton the changed files all pass locally on Linux / Python 3.10.Verification stack:
make format/ruff checkclean,mypyandpyrightclean on the changed files, focused suites plustests/sandboxpass on Linux/Python 3.10. The repository-widemypy srcreports pre-existing errors on Python 3.10 (archive_ops.pySpooledTemporaryFile typing,run_loop.pyBaseExceptionGroup export) that are unrelated to this PR, so the full-stack checkbox is left unchecked.Issue number
None (found while auditing the UnixLocal / Docker sandbox file operations).
Checks
.agents/skills/code-change-verificationindividually (format, lint, typecheck on changed files, tests)mypyerrors outside this change)/reviewbefore submitting this PR🤖 Generated with Claude Code
https://claude.ai/code/session_01BN4v25msJgrjNgac97g1Az