fix(sandbox): refuse FIFO, socket, and device nodes in UnixLocal read/write instead of blocking the event loop - #4891
Conversation
…/write instead of blocking the event loop 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: 1c019517d5
ℹ️ 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".
Open non-blocking and judge with fstat so a concurrent replacement cannot slip a FIFO past the check; probe the user-scoped write target the same way. 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: 40d2fcb2fd
ℹ️ 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".
…er permission denials 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: 700a0805d5
ℹ️ 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".
| except OSError: | ||
| kind = None |
There was a problem hiding this comment.
Perform the special-file check as the requested user
When the target is inside a directory searchable only by the requested identity, this OSError branch suppresses the host identity's EACCES; the subsequent host-side os.open() is also suppressed, and cat > "$1" then runs as the requested user and blocks indefinitely if the target is a FIFO without a reader. Fresh evidence after the earlier reply is that checking the target with stat() does not solve permission denial on a parent directory. Run the nonblocking open and descriptor classification under the requested credentials so the supported user-scoped write path cannot bypass the rejection.
AGENTS.md reference: AGENTS.md:L103-L103
Useful? React with 👍 / 👎.
| fd = os.open(workspace_path, flags, 0o666) | ||
| try: | ||
| mode = os.fstat(fd).st_mode |
There was a problem hiding this comment.
Pin the target before performing a device-triggering open
When a privileged sandbox command or host task replaces the checked regular file with a device node between stat() and this call, os.open() invokes the device before fstat() can reject it; O_NONBLOCK is device-specific, so the event loop may still block or an open-time action may already occur. Fresh evidence after the earlier reply is that the final helper now has a pre-open stat(), but the replacement-safe classification remains after the normal open. Pin and classify the entry through a non-triggering descriptor before opening that same inode for I/O.
Useful? React with 👍 / 👎.
seratch
left a comment
There was a problem hiding this comment.
Rejecting special files is worthwhile because a normal workspace FIFO can hang file I/O. The user-scoped path still bypasses the protection when only the requested user can search the parent directory: the host-side stat/open errors are suppressed, then cat opens the FIFO under that user and waits indefinitely. Please perform the nonblocking open and descriptor classification in the actual user-scoped writer, rather than treating the host precheck as authoritative. Add a no-peer FIFO regression under a user-only parent, with a subprocess-level watchdog so a regression cannot hang the test runner.
Summary
This pull request makes
UnixLocalSandboxSession.read()andwrite()refuse FIFOs, sockets, and device nodes with aWorkspaceIOErrorinstead of opening them.Bug
UnixLocalSandboxSession.read()callsworkspace_path.open("rb")andwrite()callsworkspace_path.open("wb")synchronously on the event loop.open()on a FIFO with no peer blocks the calling thread, so the whole SDK process stalls, not just the awaiting task. Against a realUnixLocalSandboxClientsession onmain(1d471a47), after the agent ranmkfifo pipein the workspace, each of these never returned (killed after 8 s bytimeout):mainsession.read(Path("pipe"))open("rb")session.read(Path("pipe"), user=...)[ -r $1 ]passes, then blocked inopen("rb")session.write(Path("pipe"), BytesIO(b"x"))open("wb")session.write(Path("pipe"), ..., user=...)cat > "$1"blocked in the execWorkspaceEditor(session).apply_patch({"type": "delete_file", "path": "pipe"})read()asyncio.wait_forcannot rescue the caller because the loop itself is blocked.ls()andrm()already handle the FIFO correctly. A model can create such an entry with a singlemkfifoin the shell tool, after which anyread_fileon it takes the agent process down.Fix
_open_regular_file()classifies the entry withstat()before opening anything, so a FIFO, socket, or device node is refused without invoking it (some device drivers block or act on open regardless ofO_NONBLOCK). It then opens the normalized (symlink-resolved) target withO_NONBLOCKand classifies the opened descriptor withfstat(), so a concurrent replacement between the two calls cannot slip a FIFO past the check. A special file is reported asWorkspaceArchiveReadError/WorkspaceArchiveWriteErrorwithcontext["reason"] = "not a regular file: <fifo|socket|character device|block device>"; a FIFO with no reader fails the non-blocking write open withENXIOand is reported the same way. Missing paths keepWorkspaceReadNotFoundError, a directory read is still reported with anIsADirectoryErrorcause, and regular files get the same buffered handle as before (O_NONBLOCKis cleared on the descriptor).The user-scoped write runs
cat > "$1"as the requested user, so it cannot classify its own descriptor;_raise_if_existing_special_file()classifies the current entry withstat()(which needs only search permission on the parent, so a target the SDK identity cannot open is still recognized) and then opens it non-blocking for the ENXIO/fstatcheck, immediately before the exec. That branch is check-then-act like the existing user-scoped access probes, and a race there stalls only the awaited exec, not the event loop.This is UnixLocal-specific: it is the only backend that opens workspace files in-process on the event loop.
Test plan
tests/sandbox/test_unix_local.py::test_unix_local_read_and_write_refuse_a_fifo_instead_of_blockingcreates a FIFO, holds anO_RDWRpeer descriptor so the previous behaviour returns instead of hanging the test, and asserts thatread(),write(), and a symlink to the FIFO are refused with the reason above while a regular file still round-trips and the FIFO survives. It fails on the previous revision withDID NOT RAISE WorkspaceArchiveReadError.test_unix_local_refuses_a_fifo_without_a_peer_and_a_directory_readcovers the no-peer case (where a blocking open never returns) underasyncio.wait_for, and the preservedIsADirectoryErrorcause for a directory read.test_open_regular_file_classifies_a_fifo_before_opening_itasserts through a monkeypatchedos.openthat a special file is rejected without being opened;test_user_scoped_write_refuses_a_fifo_the_sdk_identity_cannot_openforcesEACCESon the open and checks thestat()classification still refuses the FIFO. Both fail on the previous revision.tests/sandbox(1450 passed, 4 skipped),ruff format --check,ruff check,mypy, andpyrighton the changed files pass locally on Linux / Python 3.10.Issue number
None (found while auditing the UnixLocal sandbox file operations).
Checks
.agents/skills/code-change-verificationindividually (format, lint, typecheck on changed files, tests)mypy srchas pre-existing Python 3.10 errors outside this change)/reviewbefore submitting this PR🤖 Generated with Claude Code
https://claude.ai/code/session_01DFSMrLZZ3oq1dKmJFAofz6