Skip to content

fix(sandbox): refuse FIFO, socket, and device nodes in UnixLocal read/write instead of blocking the event loop - #4891

Open
coderdailyone wants to merge 3 commits into
openai:mainfrom
coderdailyone:fix/unix-local-refuse-non-regular-file-io
Open

fix(sandbox): refuse FIFO, socket, and device nodes in UnixLocal read/write instead of blocking the event loop#4891
coderdailyone wants to merge 3 commits into
openai:mainfrom
coderdailyone:fix/unix-local-refuse-non-regular-file-io

Conversation

@coderdailyone

@coderdailyone coderdailyone commented Sep 6, 2026

Copy link
Copy Markdown

Summary

This pull request makes UnixLocalSandboxSession.read() and write() refuse FIFOs, sockets, and device nodes with a WorkspaceIOError instead of opening them.

Bug

UnixLocalSandboxSession.read() calls workspace_path.open("rb") and write() calls workspace_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 real UnixLocalSandboxClient session on main (1d471a47), after the agent ran mkfifo pipe in the workspace, each of these never returned (killed after 8 s by timeout):

call result on main
session.read(Path("pipe")) process blocked in open("rb")
session.read(Path("pipe"), user=...) [ -r $1 ] passes, then blocked in open("rb")
session.write(Path("pipe"), BytesIO(b"x")) process blocked in open("wb")
session.write(Path("pipe"), ..., user=...) cat > "$1" blocked in the exec
WorkspaceEditor(session).apply_patch({"type": "delete_file", "path": "pipe"}) blocked in the existence check's read()

asyncio.wait_for cannot rescue the caller because the loop itself is blocked. ls() and rm() already handle the FIFO correctly. A model can create such an entry with a single mkfifo in the shell tool, after which any read_file on it takes the agent process down.

Fix

_open_regular_file() classifies the entry with stat() before opening anything, so a FIFO, socket, or device node is refused without invoking it (some device drivers block or act on open regardless of O_NONBLOCK). It then opens the normalized (symlink-resolved) target with O_NONBLOCK and classifies the opened descriptor with fstat(), so a concurrent replacement between the two calls cannot slip a FIFO past the check. A special file is reported as WorkspaceArchiveReadError / WorkspaceArchiveWriteError with context["reason"] = "not a regular file: <fifo|socket|character device|block device>"; a FIFO with no reader fails the non-blocking write open with ENXIO and is reported the same way. Missing paths keep WorkspaceReadNotFoundError, a directory read is still reported with an IsADirectoryError cause, and regular files get the same buffered handle as before (O_NONBLOCK is 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 with stat() (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/fstat check, 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

  • New tests/sandbox/test_unix_local.py::test_unix_local_read_and_write_refuse_a_fifo_instead_of_blocking creates a FIFO, holds an O_RDWR peer descriptor so the previous behaviour returns instead of hanging the test, and asserts that read(), 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 with DID NOT RAISE WorkspaceArchiveReadError.
  • test_unix_local_refuses_a_fifo_without_a_peer_and_a_directory_read covers the no-peer case (where a blocking open never returns) under asyncio.wait_for, and the preserved IsADirectoryError cause for a directory read.
  • test_open_regular_file_classifies_a_fifo_before_opening_it asserts through a monkeypatched os.open that a special file is rejected without being opened; test_user_scoped_write_refuses_a_fifo_the_sdk_identity_cannot_open forces EACCES on the open and checks the stat() classification still refuses the FIFO. Both fail on the previous revision.
  • tests/sandbox (1450 passed, 4 skipped), ruff format --check, ruff check, mypy, and pyright on the changed files pass locally on Linux / Python 3.10.

Issue number

None (found while auditing the UnixLocal sandbox file operations).

Checks

  • I've added new tests, if relevant
  • I've run the verification steps from .agents/skills/code-change-verification individually (format, lint, typecheck on changed files, tests)
  • I've confirmed all verification steps pass (the repository-wide mypy src has pre-existing Python 3.10 errors outside this change)
  • If using Codex, I've run /review before submitting this PR

🤖 Generated with Claude Code

https://claude.ai/code/session_01DFSMrLZZ3oq1dKmJFAofz6

…/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

@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: 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".

Comment thread src/agents/sandbox/sandboxes/unix_local.py Outdated
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

@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: 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".

Comment thread src/agents/sandbox/sandboxes/unix_local.py Outdated
Comment thread src/agents/sandbox/sandboxes/unix_local.py
…er permission denials

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFSMrLZZ3oq1dKmJFAofz6

@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: 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".

Comment on lines +226 to +227
except OSError:
kind = None

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 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 👍 / 👎.

Comment on lines +199 to +201
fd = os.open(workspace_path, flags, 0o666)
try:
mode = os.fstat(fd).st_mode

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants