Skip to content

fix(sandbox): remove a symlink itself in UnixLocal rm, not its target - #4830

Open
coderdailyone wants to merge 3 commits into
openai:mainfrom
coderdailyone:fix/unix-local-rm-symlink
Open

fix(sandbox): remove a symlink itself in UnixLocal rm, not its target#4830
coderdailyone wants to merge 3 commits into
openai:mainfrom
coderdailyone:fix/unix-local-rm-symlink

Conversation

@coderdailyone

Copy link
Copy Markdown

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), and rm() operated on that resolved path. Against a real UnixLocalSandboxClient session on main (89c02c8), after the agent ran ln -s plain.txt linkfile; ln -s realdir linkdir; ln -s /etc/hostname outside_file; ln -s /nonexistent dangling in the workspace:

call result on main
rm("linkfile") deleted plain.txt; linkfile left dangling
rm("linkdir", recursive=True) shutil.rmtree on realdir/ (all contents gone); linkdir left dangling
rm("dangling") InvalidManifestPathError: manifest path must not escape root — cannot be removed
rm("outside_file") same error — cannot be removed

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() runs rm -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-scoped rm(..., 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.
  • Paths that reach an entry through an escaping symlinked parent (e.g. rm("escape_dir/victim.txt") where escape_dir -> /outside) are still rejected with InvalidManifestPathError.

Behavior change

rm on a symlink inside a UnixLocal workspace now removes only the link, matching POSIX rm, rm -rf, and every other sandbox backend. No public signatures change.

Evidence

  • New tests in tests/sandbox/test_unix_local.py::TestUnixLocalRmSymlinks cover file symlink, directory symlink (recursive and not), symlink pointing outside the workspace, dangling symlink, the escaping-parent rejection, and the user-scoped path. Six of the seven fail on main and pass with this change.
  • tests/sandbox (1453 passed), the full parallel suite (9261 passed; two tests/mcp/test_mcp_pagination_integration.py stdio tests failed under a memory-constrained 3-worker run and pass when rerun alone), serial tests, ruff format --check, ruff check, and mypy/pyright on the changed files all pass locally on Linux / Python 3.10.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BN4v25msJgrjNgac97g1Az

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

POSIX ``rm`` and the exec-backed sessions remove the link itself, so validate the entry's
parent directory (following symlinks) and keep the leaf name unresolved.
"""
raw_path = Path(path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate raw paths before splitting off the rm leaf

On Unix, when path is a raw drive-absolute Windows string such as C:\\outside\\file, Path(path) treats the backslashes as ordinary filename characters, so only . is normalized and the call deletes <workspace>/C:\\outside\\file. Previously, normalize_path() recognized this as an absolute Windows path and raised InvalidManifestPathError; validate the original input with the canonical workspace path helpers before separating the unresolved leaf.

AGENTS.md reference: AGENTS.md:L205-L205

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed in e505c43. _rm_target_path() now runs the raw input through WorkspacePathPolicy.normalize_path(path, for_write=True) (no symlink following) before splitting off the leaf, so a Windows drive-absolute string such as C:\outside\file raises InvalidManifestPathError exactly as before instead of being treated as a literal entry name under the workspace root. Added test_rm_rejects_absolute_and_escaping_raw_paths_before_splitting_the_leaf, which fails on the previous revision for the C:\... case and passes now.

Comment on lines 975 to +976
if user is not None:
normalized = await self._check_rm_with_exec(path, recursive=recursive, user=user)
else:
normalized = self.normalize_path(path, for_write=True)
await self._check_rm_access_with_exec(normalized, recursive=recursive, user=user)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce sticky-directory ownership for user-scoped rm

When user= names a non-owner removing a symlink from a sticky world-writable directory, _RM_ACCESS_CHECK_SCRIPT succeeds because it checks only parent write/execute permission, even though an actual rm as that user is rejected by the sticky bit. The subsequent normalized.unlink() runs as the SDK process and therefore deletes an entry the requested user cannot remove; execute the removal under the requested identity or extend the check to enforce sticky-directory ownership.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pre-existing behavior of the user-scoped path rather than something this change introduces: before this PR, rm("file", user=...) ran the same _RM_ACCESS_CHECK_SCRIPT against the same parent directory and then unlinked as the SDK process, so a non-owner in a sticky world-writable directory was already able to remove another user's regular file. The only difference now is that the check and the unlink target the symlink entry instead of its resolved target, which is the intended rm semantics.

I'd like to keep this PR scoped to the symlink-target bug. Enforcing sticky-directory ownership in the access check (or performing the removal under the requested identity) affects every user-scoped rm, not just symlinks, and is hard to exercise in the test suite because CI does not run as a second uid. Happy to send it as a follow-up PR if maintainers want it.

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +968 to +969
parent = self.normalize_path(raw_path.parent, for_write=True)
return parent / raw_path.name

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Recheck the reconstructed leaf against nested grants

When a workspace symlink points into a writable extra grant and the leaf is itself a more-specific read-only grant—for example, tmp-link -> /tmp/shared with /tmp/shared/protected read-only—rm("tmp-link/protected", recursive=True) validates only the writable parent and then appends protected without applying the longest matching grant. The host-side rmtree can therefore delete the read-only grant root, whereas the previous full-path normalization rejected it; validate the reconstructed entry against the canonical grant policy without following the leaf symlink.

AGENTS.md reference: AGENTS.md:L95-L95

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and thanks — this and the root-alias comment below have the same root cause, so I replaced the hand-built parent/leaf split. WorkspacePathPolicy.normalize_path() gained follow_leaf_symlink=False (with resolve_symlinks=True it resolves the parents, keeps the final component, and then applies the workspace root, the longest matching extra grant, and the read-only check to that location). _rm_target_path() is now a single call into that. Regression test test_rm_applies_the_most_specific_grant_to_the_leaf covers exactly this shape (tmp-link -> <writable grant> with a nested read-only grant root) and fails on the previous revision.

# Validate the raw input with the workspace path policy first (without following
# symlinks) so Windows-absolute strings and lexical escapes are rejected exactly as
# before, instead of being split into a leaf name under the workspace root.
self._workspace_path_policy().normalize_path(path, for_write=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve paths resolved through the configured root alias

When Manifest.root is a symlink such as /tmp/ws-link -> /tmp/ws, an absolute resolved path such as /tmp/ws/file is accepted by UnixLocalSandboxSession.normalize_path() and can be returned as FileEntry.path by ls(). This new non-resolving precheck instead compares it with the lexical /tmp/ws-link root and raises InvalidManifestPathError, so callers can no longer pass that listed path back to rm() even though the old implementation accepted it. Preserve the canonical resolved-root behavior while separately rejecting invalid raw syntax.

AGENTS.md reference: AGENTS.md:L95-L95

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed together with the comment above: the lexical precheck is gone, and validation now goes through the policy's resolving branch, which compares against the resolved root. test_rm_accepts_paths_resolved_through_a_symlinked_root sets Manifest.root to ws-link -> ws and removes entries both by their resolved path and by their root-alias path; it fails on the previous revision and passes now.

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 sylvesterkaczmarek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@coderdailyone

Copy link
Copy Markdown
Author

Thanks for looking at this. I agree there is a rename race between authorizing the parent and the unlink()/rmtree() call, but I don't think this change introduces it: main already does normalized = normalize_path(...) (a Path.resolve()) and then shutil.rmtree(normalized) / normalized.unlink() by pathname, and read(), write(), mkdir() and ls() follow the same resolve-then-operate-by-path pattern. The window is the same size before and after; what changed is only which entry is removed (the link rather than its target).

Closing that class properly means pinning the validated directory (open with O_DIRECTORY, fstat against the inode seen at validation, then unlink(name, dir_fd=...) / rmdir(name, dir_fd=...)) across every UnixLocal file operation, and shutil.rmtree only grew dir_fd in Python 3.11 while this package supports 3.10. It is also worth keeping in mind that UnixLocal runs exec unconfined on Linux as the same user, so a process able to race the parent already has direct host access; the path policy is a guardrail for the file API rather than a boundary against a concurrent hostile process.

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.

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.

2 participants