fix(sandbox): refuse to rm the workspace root - #4832
Conversation
`rm(".")`, `rm("")` and `rm("<workspace root>")` passed path validation
(the root is a valid workspace path) and then removed the workspace
directory itself: BaseSandboxSession ran `rm -rf -- /workspace` in the
sandbox and UnixLocalSandboxSession called shutil.rmtree on the host
directory. Every later exec, read or write in the session then failed
with WorkspaceRootNotFoundError.
Reject the root before removing anything, in both the exec-backed and
the UnixLocal implementation, with a WorkspaceArchiveWriteError whose
context names the reason. Removing entries under the root is unchanged.
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: d04dd3b6e4
ℹ️ 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".
| root = Path(self.state.manifest.root) | ||
| candidates = {sandbox_path_str(root), sandbox_path_str(self._workspace_root_path())} | ||
| try: | ||
| candidates.add(sandbox_path_str(root.resolve(strict=False))) |
There was a problem hiding this comment.
Resolve the root only in the local backend
When a remote sandbox's POSIX root resolves differently on the SDK host—for example, root="/tmp/ws" on macOS while the remote sandbox also has a writable grant at /private/tmp/ws—this host-side resolve() adds the unrelated grant path to the protected candidates. _validate_remote_path_access() legitimately accepts that grant, but rm("/private/tmp/ws", recursive=True) is then falsely rejected as workspace-root removal based solely on the client filesystem. Keep remote comparisons POSIX-only and perform realpath comparison in the UnixLocal boundary that needs it.
AGENTS.md reference: AGENTS.md:L205-L205
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed. The base guard now compares POSIX spellings only (Manifest.root and the policy's sandbox_root()), with no host-side resolve(). UnixLocalSandboxSession overrides _raise_if_workspace_root_removal() to additionally compare against the resolved root, since its validated paths are host realpaths and Manifest.root (or /tmp on macOS) may be a symlink. The existing parametrized tests cover both layers.
…ssion The root guard resolved Manifest.root on the SDK host, which is the wrong filesystem for a remote sandbox and could turn an unrelated host path into a protected candidate. Keep the base comparison POSIX-only and do the realpath comparison in UnixLocalSandboxSession, whose validated paths are host realpaths. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BN4v25msJgrjNgac97g1Az
This pull request fixes
SandboxSession.rm()so it refuses to remove the workspace root itself instead of deleting the whole workspace.Bug
The workspace root is a valid workspace path, so
rm("."),rm(""),rm("sub/..")andrm("<root>")all pass_validate_path_access()/normalize_path()and then run:BaseSandboxSession.rm()(every exec-backed backend):rm -rf -- /workspaceinside the sandbox;UnixLocalSandboxSession.rm():shutil.rmtree(<root>)on the host.After that the session is unusable. Reproduced on
mainwith a realUnixLocalSandboxClientsession:await session.rm(".", recursive=True)succeeds, the/tmp/sandbox-local-*directory is gone, and the nextsession.exec("pwd")raisesWorkspaceRootNotFoundError. The same call is reachable from the model throughapply_patch(delete_filewith path.) and any tool that forwards a path torm.Removing the root is never what a caller means by a file operation; clearing a workspace is
rmof its entries.Fix
BaseSandboxSession._raise_if_workspace_root_removal(path)compares the validated path with the manifest root, the policy'ssandbox_root(), and the resolved root (for UnixLocal, whose validated paths are realpaths and whose/tmpmay itself be a symlink), and raisesWorkspaceArchiveWriteError(context={"reason": "workspace_root_removal_refused"}).BaseSandboxSession.rm()andUnixLocalSandboxSession.rm()call it after path validation and before any exec or filesystem mutation.rmof entries under the root is unchanged.Evidence
tests/sandbox/test_session_utils.py::test_rm_refuses_to_remove_the_workspace_root(exec-backed base, five spellings of the root, asserts normcommand is executed) andtests/sandbox/test_unix_local.py::TestUnixLocalRmWorkspaceRoot(real filesystem, asserts the workspace and its contents survive). Ten parametrized cases fail onmainand pass with this change; the companion tests assertrmof a child entry still works.tests/sandbox/test_unix_local.py,tests/sandbox/test_session_utils.py,ruff,mypy, andpyrighton the changed files pass locally (Linux, Python 3.10).Note: this touches the same
rm()inunix_local.pyas #4830 (leaf-symlink removal); the two changes are independent and either can be rebased trivially onto the other.🤖 Generated with Claude Code
https://claude.ai/code/session_01BN4v25msJgrjNgac97g1Az