Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 53 additions & 26 deletions src/agents/sandbox/sandboxes/unix_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import shlex
import shutil
import signal
import stat
import tarfile
import tempfile
import termios
Expand Down Expand Up @@ -68,7 +69,7 @@
safe_extract_tarfile,
should_skip_tar_member,
)
from ..workspace_paths import _raise_if_filesystem_root
from ..workspace_paths import _raise_if_filesystem_root, coerce_posix_path

_DEFAULT_WORKSPACE_PREFIX = "sandbox-local-"
_DEFAULT_MANIFEST_ROOT = cast(str, Manifest.model_fields["root"].default)
Expand Down Expand Up @@ -112,6 +113,26 @@ def _close_fd_quietly(fd: int) -> None:
os.close(fd)


def _unix_file_entry(path: str, stat_result: os.stat_result) -> FileEntry:
mode = stat_result.st_mode
if stat.S_ISLNK(mode):
kind = EntryKind.SYMLINK
elif stat.S_ISDIR(mode):
kind = EntryKind.DIRECTORY
elif stat.S_ISREG(mode):
kind = EntryKind.FILE
else:
kind = EntryKind.OTHER
return FileEntry(
path=path,
permissions=Permissions.from_mode(mode),
owner=str(stat_result.st_uid),
group=str(stat_result.st_gid),
size=stat_result.st_size,
kind=kind,
)


def _restore_pty_child_signal_defaults() -> None:
for signum in _PTY_CHILD_SIGNAL_DEFAULTS:
signal.signal(signum, signal.SIG_DFL)
Expand Down Expand Up @@ -891,6 +912,24 @@ def normalize_path(self, path: Path | str, *, for_write: bool = False) -> Path:
policy = self._workspace_path_policy()
return policy.normalize_path(path, for_write=for_write, resolve_symlinks=True)

def _requested_leaf_path(self, path: Path | str) -> Path:
"""Validate `path` and return the entry itself without following a leaf symlink.

One POSIX identity of the input serves both checks: the fully resolved target must be
confined (an escaping link is rejected as before), and then the leaf's own location,
with only its parents resolved, must be under the workspace root or an extra grant.
The leaf is reported as itself, the way `ls -la <link>` prints it, so a symlink keeps
its kind and path, including under a symlinked Manifest.root.
"""
canonical = coerce_posix_path(path)
self.normalize_path(canonical.as_posix())
return self._workspace_path_policy().normalize_path(
canonical, resolve_symlinks=True, follow_leaf_symlink=False
)

async def _validate_listing_path(self, path: Path | str) -> Path:
return self._requested_leaf_path(path)

async def ls(
self,
path: Path | str,
Expand All @@ -900,32 +939,20 @@ async def ls(
if user is not None:
return await super().ls(path, user=user)

normalized = self.normalize_path(path)
command = ("ls", "-la", "--", str(normalized))
requested = self._requested_leaf_path(path)
command = ("ls", "-la", "--", str(requested))
try:
with os.scandir(normalized) as entries:
listed: list[FileEntry] = []
for entry in entries:
stat_result = entry.stat(follow_symlinks=False)
if entry.is_symlink():
kind = EntryKind.SYMLINK
elif entry.is_dir(follow_symlinks=False):
kind = EntryKind.DIRECTORY
elif entry.is_file(follow_symlinks=False):
kind = EntryKind.FILE
else:
kind = EntryKind.OTHER
listed.append(
FileEntry(
path=entry.path,
permissions=Permissions.from_mode(stat_result.st_mode),
owner=str(stat_result.st_uid),
group=str(stat_result.st_gid),
size=stat_result.st_size,
kind=kind,
)
)
return listed
# Match `ls -la <path>`: a directory lists its entries; anything else, including a
# symlink (even one to a directory), is reported as the single entry itself. The
# exec-backed sessions return the same shape.
stat_result = requested.lstat()
if not stat.S_ISDIR(stat_result.st_mode):
return [_unix_file_entry(str(requested), stat_result)]
with os.scandir(requested) as entries:
return [
_unix_file_entry(entry.path, entry.stat(follow_symlinks=False))
for entry in entries
]
except OSError as e:
raise ExecNonZeroError(
ExecResult(stdout=b"", stderr=str(e).encode("utf-8"), exit_code=1),
Expand Down
6 changes: 5 additions & 1 deletion src/agents/sandbox/session/base_sandbox_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,10 @@ def _workspace_root_path(self) -> Path:
async def _validate_path_access(self, path: Path | str, *, for_write: bool = False) -> Path:
return self.normalize_path(path, for_write=for_write)

async def _validate_listing_path(self, path: Path | str) -> Path:
"""Validate the path ``ls`` lists; backends that resolve symlinks keep the leaf."""
return await self._validate_path_access(path)

async def _validate_remote_path_access(
self,
path: Path | str,
Expand Down Expand Up @@ -1112,7 +1116,7 @@ async def ls(
:param user: Optional sandbox user to list as.
:returns: A list of `FileEntry` objects.
"""
path = await self._validate_path_access(path)
path = await self._validate_listing_path(path)

path_arg = sandbox_path_str(path)
cmd = ("ls", "-la", "--", path_arg)
Expand Down
2 changes: 1 addition & 1 deletion src/agents/sandbox/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def from_mode(cls, mode: int) -> "Permissions":
owner=(mode >> 6) & 0b111,
group=(mode >> 3) & 0b111,
other=(mode >> 0) & 0b111,
directory=bool(mode & stat.S_IFDIR),
directory=stat.S_ISDIR(mode),
)

@classmethod
Expand Down
24 changes: 21 additions & 3 deletions src/agents/sandbox/workspace_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,11 +368,15 @@ def normalize_path(
*,
for_write: bool = False,
resolve_symlinks: bool = False,
follow_leaf_symlink: bool = True,
) -> Path:
"""Return a validated absolute path under the workspace or an extra grant.

`resolve_symlinks` follows symlinks on the host filesystem. Use it only when the sandbox
workspace is a real local host directory, such as UnixLocalSandboxSession.
With `follow_leaf_symlink=False`, only the parent directories are resolved and the
final path component is kept as the entry itself, so an operation on a symlink (such as
listing or removing it) is validated at the link's own location rather than its target.
"""

if resolve_symlinks:
Expand All @@ -382,7 +386,9 @@ def normalize_path(
raise self._invalid_path_error(windows_path)
else:
original = Path(path)
result, grant = self._resolved_host_path_and_grant(original)
result, grant = self._resolved_host_path_and_grant(
original, follow_leaf_symlink=follow_leaf_symlink
)
else:
if (windows_path := windows_absolute_path(path)) is not None:
native_path = _native_path_from_windows_absolute(windows_path)
Expand Down Expand Up @@ -427,13 +433,25 @@ def root_is_existing_host_path(self) -> bool:
def _resolved_host_path_and_grant(
self,
original: Path,
*,
follow_leaf_symlink: bool = True,
) -> tuple[Path, SandboxPathGrant | None]:
workspace_root = self._root.resolve(strict=False)
if original.is_absolute():
resolved = original.resolve(strict=False)
absolute_path = original
else:
absolute = self._absolute_workspace_posix_path(coerce_posix_path(original))
resolved = Path(str(absolute)).resolve(strict=False)
absolute_path = Path(str(absolute))
# The workspace root itself is always addressed through its resolved form, so a
# symlinked root alias stays authorized; only entries below it keep their leaf.
if (
follow_leaf_symlink
or absolute_path.name in ("", ".", "..")
or absolute_path == Path(self._normalized_root().as_posix())
):
resolved = absolute_path.resolve(strict=False)
else:
resolved = absolute_path.parent.resolve(strict=False) / absolute_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.

P2 Badge Allow listing through a symlinked workspace root

When Manifest.root is a symlink and the caller lists the normal root path "." (or the configured root alias itself), absolute_path becomes that alias and this line preserves it while only resolving its parent. The subsequent containment check compares the alias against the resolved workspace target and raises InvalidManifestPathError, so the workspace root cannot be listed. Fresh evidence beyond the earlier resolved-root report is the root path itself—the added test covers only a child addressed through the resolved root. Treat the configured root alias as authorized when the requested leaf is the root, while preserving leaf symlinks below it.

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. In the leaf-preserving branch the workspace root itself (relative ./empty, or the configured root path, i.e. the alias) is now always addressed through its resolved form, so it stays authorized under a symlinked Manifest.root; only entries below the root keep their leaf identity. test_ls_lists_a_symlinked_workspace_root covers ., "", <alias>, and <alias>/; all four fail on the previous revision and pass now.


if self._is_under(resolved, workspace_root):
return resolved, None
Expand Down
Loading