Skip to content
Open
25 changes: 24 additions & 1 deletion src/agents/sandbox/apply_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,11 @@ async def apply_operation(
path=operation.path,
cause=exc,
) from exc
await self._write_text(destination, created_text)
# Hand over the unresolved path. destination has already been through
# normalize_path(), which resolves leaf symlinks on some backends, so passing
# it would ask the backend to create the link target instead of the requested
# name and a dangling link would be reported as a successful create.
await self._write_new_text(relative_path, created_text, display_path=display_path)
return ApplyPatchResult(output=f"Created {display_path}")

raise ApplyPatchDiffError(
Expand Down Expand Up @@ -186,6 +190,25 @@ async def _ensure_exists(self, destination: Path, *, display_path: str) -> None:
else:
handle.close()

async def _write_new_text(self, destination: Path, text: str, *, display_path: str) -> None:
# Add File is documented as creating a new file, so the name is claimed
# exclusively by the backend rather than checked and then overwritten.
try:
await self._session.write_new_file(
destination,
io.BytesIO(text.encode("utf-8")),
user=self._user,
)
except FileExistsError as exc:
raise ApplyPatchDiffError(
message=(
f"apply_patch cannot create {display_path} because it already exists. "
"Use an update_file operation to change an existing file."
),
path=display_path,
cause=exc,
) from exc

async def _read_text(self, destination: Path, *, op_path: str, decode_path: Path) -> str:
try:
handle = await self._session.read(destination, user=self._user)
Expand Down
50 changes: 50 additions & 0 deletions src/agents/sandbox/sandboxes/unix_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,56 @@ async def write(
except OSError as e:
raise WorkspaceArchiveWriteError(path=workspace_path, cause=e) from e

async def write_new_file(
self,
path: Path,
data: io.IOBase,
*,
user: str | User | None = None,
) -> None:
if user is not None:
await super().write_new_file(path, data, user=user)
return

payload = coerce_write_payload(path=path, data=data)
# Validate the parent with the normal policy so grants and symlinked parents are
# still enforced, then keep the final component unresolved. normalize_path()
# resolves symlinks, which would turn a dangling link at the target name into its
# absent target and let the write land there instead of being rejected.
requested = Path(path)
parent_path = self.normalize_path(requested.parent, for_write=True)
workspace_path = parent_path / requested.name
staging_path = parent_path / f".apply-patch-create-{uuid.uuid4().hex}"
# Classify a visible collision before staging, so a target inside a non-writable
# parent reports the collision rather than a permission failure from the staging
# write. os.path.lexists does not follow a symlink at the target name.
if os.path.lexists(workspace_path):
raise FileExistsError(str(workspace_path))

try:
# Only the link may report a collision. A parent that is a regular file also
# raises FileExistsError from mkdir, and reporting that as "the target already
# exists" would send the model to update_file for a target that is absent.
try:
parent_path.mkdir(parents=True, exist_ok=True)
with staging_path.open("wb") as staged:
shutil.copyfileobj(payload.stream, staged)
except OSError as e:
raise WorkspaceArchiveWriteError(path=workspace_path, cause=e) from e

# os.link claims the name in one step and fails with EEXIST when it is taken
# by anything, including a directory or a dangling symlink. Linking a complete
# payload means a failed write never leaves a file holding the name.
try:
os.link(staging_path, workspace_path)
except FileExistsError:
raise
except OSError as e:
raise WorkspaceArchiveWriteError(path=workspace_path, cause=e) from e
finally:
with suppress(OSError):
staging_path.unlink()

async def _write_stream_with_exec(
self,
path: Path,
Expand Down
100 changes: 100 additions & 0 deletions src/agents/sandbox/session/base_sandbox_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
import asyncio
import io
import shlex
import uuid
from collections.abc import Awaitable, Callable, Mapping, Sequence
from contextlib import suppress
from pathlib import Path, PurePath
from typing import Literal, NoReturn, TypeVar

Expand Down Expand Up @@ -149,6 +151,31 @@
fi
done
""".strip()
_EXCLUSIVE_CREATE_EXISTS_CODE = 13
# Classify an already-visible target before any payload is staged. Without this the
# staging write runs first, so a target inside an executable but non-writable parent
# fails on permissions and the caller sees a write error instead of the collision error
# that tells it to use update_file. The atomic claim below still decides real races.
_TARGET_EXISTS_SCRIPT = 'target="$1"\nif [ -e "$target" ] || [ -L "$target" ]; then exit 13; fi\n'

# ``ln`` claims the target name and fails when that name is already taken. Linking a
# fully written staging file means the content is complete before the name exists, so a
# failed or cancelled upload cannot leave a file behind that holds the name. The leading
# test rejects a name held by a directory, which ``ln`` would otherwise treat as a target
# directory and populate; the trailing test only classifies a failure, so "already
# exists" stays separable from any other error without parsing shell-specific stderr.
# ``ln`` is a regular command, unlike ``:``, so its failure still reaches the explicit
# exit mapping on shells where ``:`` is a special builtin. The caller creates the parent,
# so this script never has to create one as a different identity.
_EXCLUSIVE_CREATE_SCRIPT = (
'target="$1"\n'
'source="$2"\n'
'if [ -e "$target" ] || [ -L "$target" ]; then exit 13; fi\n'
'ln "$source" "$target" 2>/dev/null && exit 0\n'
Comment thread
seratch marked this conversation as resolved.
Comment thread
seratch marked this conversation as resolved.
'if [ -e "$target" ] || [ -L "$target" ]; then exit 13; fi\n'
"exit 14\n"
)
Comment thread
seratch marked this conversation as resolved.

_WRITE_ACCESS_CHECK_SCRIPT = (
'target="$1"\n'
'if [ -e "$target" ]; then\n'
Expand Down Expand Up @@ -945,6 +972,79 @@ async def write(
:param user: Optional sandbox user to perform the write as.
"""

async def write_new_file(
self,
path: Path,
data: io.IOBase,
*,
user: str | User | None = None,
) -> None:
"""Write a file that must not already exist.

The target name is claimed in a single atomic step once the payload is complete,
so a concurrent creator either loses the race or keeps its own content, and a
failed write does not leave a partial file holding the name.

:param path: Absolute path in the container or path relative to the
workspace root.
:param data: A file-like object positioned at the start of the payload.
:param user: Optional sandbox user to perform the write as.
:raises FileExistsError: If the path already exists, including a dangling symlink.
"""
# Validate the parent so grants and symlinked parents are still enforced, then
# keep the final component unresolved. A path policy that resolves symlinks would
# otherwise turn a dangling link at the target name into its absent target and let
# the create land there instead of being rejected.
requested = Path(path)
parent_path = await self._validate_path_access(requested.parent, for_write=True)
workspace_path = parent_path / requested.name
path_arg = sandbox_path_str(workspace_path)
# A fixed-length staging basename. Deriving it from the destination made the
# staging name longer than the destination, so a name that fits the filesystem's
# component limit could still fail to stage.
staging_path = parent_path / f".apply-patch-create-{uuid.uuid4().hex}"
staging_arg = sandbox_path_str(staging_path)

preflight = await self.exec(
"sh", "-c", _TARGET_EXISTS_SCRIPT, "sh", path_arg, shell=False, user=user
)
if preflight.exit_code == _EXCLUSIVE_CREATE_EXISTS_CODE:
raise FileExistsError(path_arg)

try:
# Create the parent as the bound user so a fresh nested path is owned the same
# way the ordinary write path owned it.
await self.mkdir(parent_path, parents=True, user=user)
await self.write(staging_path, data, user=user)
Comment thread
ayaangazali marked this conversation as resolved.

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 Avoid cross-owner hard links for run_as creates

When SandboxAgent.run_as names a non-default user on Modal, ModalSandboxSession.write() only checks permissions as that user and then creates the staging file through an unscoped cat, so the file is owned by the provider's default identity; this later runs ln as run_as. On standard Linux sandboxes with fs.protected_hardlinks=1, that user cannot hard-link a typical 0644 file owned by another identity, so every otherwise-valid Add File exits 14 and surfaces WorkspaceArchiveWriteError. Creating the parent as the bound user does not address this separate source-inode ownership restriction; stage the payload using the same effective user or use an exclusive operation that does not rely on cross-owner hard links.

AGENTS.md reference: AGENTS.md:L102-L103

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This one I agree with and am deliberately not patching, because it is evidence that the mechanism is wrong rather than a missing condition.

With run_as on a backend whose write() permission-checks the requested user and then uploads as the provider identity, the staging file is owned by that provider identity while ln runs as run_as. Under fs.protected_hardlinks=1, which is the default on standard Linux, that link is refused for a 0644 file owned by another identity, so an otherwise valid Add File exits 14 and surfaces WorkspaceArchiveWriteError. Creating the parent as the bound user does not help, as you say.

That is the second independent case where the shared hard-link approach fails on a supported configuration; the first is writable rclone, Mountpoint and Blobfuse mounts, which have no POSIX hard links at all. Since exec() has no stdin, the shared implementation cannot stream a payload and claim a name in one step, which leaves only create-empty-then-write, already rejected here because a concurrent writer is overwritten and a failed upload leaves an empty file holding the name.

So I do not think another condition fixes this. There is an open question further up the thread about whether the shared shell implementation should exist at all, or whether exclusive create should be a per-backend primitive with UnixLocal being the one I can actually verify. I have asked the maintainers to rule on that and I would rather not keep hardening a path that may be removed.

# -c rather than -lc: this runs on a filesystem-only capability set, so it
# must not source workspace-writable shell startup files.
result = await self.exec(
"sh",
"-c",
_EXCLUSIVE_CREATE_SCRIPT,
Comment thread
ayaangazali marked this conversation as resolved.
"sh",
path_arg,
staging_arg,
shell=False,
user=user,
Comment thread
seratch marked this conversation as resolved.
)
if result.exit_code == _EXCLUSIVE_CREATE_EXISTS_CODE:
raise FileExistsError(path_arg)
if not result.ok():
raise WorkspaceArchiveWriteError(
path=workspace_path,
context={
"command": ["sh", "-c", "<exclusive_create>", path_arg, staging_arg],
"stdout": result.stdout.decode("utf-8", errors="replace"),
"stderr": result.stderr.decode("utf-8", errors="replace"),
},
)
finally:
# The staging entry is an implementation detail, and removing it must not
# replace the outcome of the create.
with suppress(Exception):
await self.rm(staging_path, 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 Make staging cleanup ownership-safe

When an apply_patch create runs alongside a shell or custom tool, the other operation can observe the staging name and replace that directory entry after the awaited link command completes; this unconditional rm then deletes the replacement even though it is no longer the staging file owned by this operation. Cleanup should verify the staged entry's identity or use a private/atomic backend primitive so stale cleanup cannot dispose state created by surviving concurrent work.

AGENTS.md reference: AGENTS.md:L104-L104

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Real, and it is the AGENTS.md rule about stale cleanup disposing state owned by surviving concurrent work, so I am not disputing the pattern.

Two things narrow it since the comment was written. The staging entry now only exists when the probe found the target absent, so the window is gone entirely for the common rejected-create case. And the name is a random .apply-patch-create-<32 hex>, so another tool has to observe that specific entry and replace it inside the window.

That is smaller, not zero, and the correct fix is what you describe: verify the staged entry identity before removing it, or use a primitive that keeps the staging entry private. I am holding it for the same reason as the hard-link thread above. Doing it locally is a cheap lstat inode comparison, but doing it in the shared shell path is another round trip on an implementation whose existence is currently an open question for the maintainers. Once that is settled I will implement the identity check in whichever primitive survives.


async def _check_read_with_exec(
self, path: Path | str, *, user: str | User | None = None
) -> Path:
Expand Down
13 changes: 13 additions & 0 deletions src/agents/sandbox/session/sandbox_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,19 @@ async def write(
) -> None:
await self._inner.write(path, data, user=user)

@instrumented_op("write", data=_write_start_data)
async def write_new_file(
self,
path: Path,
data: io.IOBase,
*,
user: str | User | None = None,
) -> None:
# Forwarded so a backend with a native exclusive-create primitive is actually
# used. Without this the wrapper would fall back to the shared implementation and
# bypass the inner session's override.
await self._inner.write_new_file(path, data, user=user)

@instrumented_op(
"running",
finish_data=_running_finish_data,
Expand Down
14 changes: 14 additions & 0 deletions tests/sandbox/_apply_patch_test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,20 @@ async def write(
else:
self.files[normalized] = bytes(payload)

async def write_new_file(
self,
path: Path,
data: io.IOBase,
*,
user: str | User | None = None,
) -> None:
normalized = self.normalize_path(path)
if normalized in self.files:
raise FileExistsError(str(normalized))
# Real backends create the parents inside the primitive, so record that here too.
await self.mkdir(normalized.parent, parents=True, user=user)
await self.write(path, data, user=user)

async def _exec_internal(
self,
*command: str | Path,
Expand Down
49 changes: 49 additions & 0 deletions tests/sandbox/test_apply_patch.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import io
from pathlib import Path

import pytest
Expand All @@ -11,7 +12,9 @@
ApplyPatchDiffError,
ApplyPatchFileNotFoundError,
ApplyPatchPathError,
WorkspaceReadNotFoundError,
)
from agents.sandbox.types import User
from tests.sandbox._apply_patch_test_session import (
ApplyPatchSession,
ProviderNotFoundApplyPatchSession,
Expand Down Expand Up @@ -411,3 +414,49 @@ async def test_apply_patch_mapping_operation_rejects_non_string_move_to() -> Non
)

assert session.files[Path("/workspace/old.txt")] == b"alpha\n"


@pytest.mark.asyncio
async def test_apply_patch_create_rejects_an_existing_file() -> None:
session = ApplyPatchSession()
session.files[Path("/workspace/notes.txt")] = b"alpha\n"

with pytest.raises(ApplyPatchDiffError):
await session.apply_patch(
ApplyPatchOperation(
type="create_file",
path="notes.txt",
diff="+beta\n",
)
)

assert session.files[Path("/workspace/notes.txt")] == b"alpha\n"


class _AlwaysMissingReadApplyPatchSession(ApplyPatchSession):
"""Reports every path as missing while still holding the file.

A create that only probed with read() would be told the path is free and would
overwrite the stored content, so this pins the rejection to the write boundary.
"""

async def read(self, path: Path, *, user: str | User | None = None) -> io.BytesIO:
_ = (path, user)
raise WorkspaceReadNotFoundError(path=path)


@pytest.mark.asyncio
async def test_apply_patch_create_rejects_an_existing_file_without_reading_it() -> None:
session = _AlwaysMissingReadApplyPatchSession()
session.files[Path("/workspace/notes.txt")] = b"alpha\n"

with pytest.raises(ApplyPatchDiffError):
await session.apply_patch(
ApplyPatchOperation(
type="create_file",
path="notes.txt",
diff="+beta\n",
)
)

assert session.files[Path("/workspace/notes.txt")] == b"alpha\n"
Loading