Skip to content

Commit b60142e

Browse files
committed
fix nested extra path issue
1 parent a436416 commit b60142e

9 files changed

Lines changed: 310 additions & 30 deletions

File tree

src/agents/sandbox/sandboxes/unix_local.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,11 @@ def _literal(path: Path | str) -> str:
757757
for path, read_only in extra_path_grants
758758
if not read_only
759759
],
760+
*[
761+
f"(deny file-write* (subpath {_literal(path)}))"
762+
for path, read_only in extra_path_grants
763+
if read_only
764+
],
760765
'(allow file-read-data file-read-metadata (subpath "/usr/bin"))',
761766
'(allow file-read-data file-read-metadata (subpath "/usr/lib"))',
762767
'(allow file-read-data file-read-metadata (subpath "/bin"))',

src/agents/sandbox/session/base_sandbox_session.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -706,12 +706,17 @@ async def _validate_remote_path_access(
706706
path_policy = self._workspace_path_policy()
707707
workspace_path = path_policy.normalize_path(original_path, for_write=for_write)
708708
helper_path = await self._ensure_runtime_helper_installed(RESOLVE_WORKSPACE_PATH_HELPER)
709-
extra_roots = path_policy.extra_path_grant_roots(writable_only=for_write)
709+
extra_grant_args = tuple(
710+
arg
711+
for root, read_only in path_policy.extra_path_grant_rules()
712+
for arg in (str(root), "1" if read_only else "0")
713+
)
710714
command = (
711715
str(helper_path),
712716
str(root),
713717
str(workspace_path),
714-
*(str(extra_root) for extra_root in extra_roots),
718+
"1" if for_write else "0",
719+
*extra_grant_args,
715720
)
716721
result = await self.exec(*command, shell=False)
717722
if result.ok():
@@ -721,7 +726,13 @@ async def _validate_remote_path_access(
721726
# semantics while the remote realpath check still enforces path confinement.
722727
return workspace_path
723728
raise ExecTransportError(
724-
command=("resolve_workspace_path", str(root), str(workspace_path), *extra_roots),
729+
command=(
730+
"resolve_workspace_path",
731+
str(root),
732+
str(workspace_path),
733+
"1" if for_write else "0",
734+
*extra_grant_args,
735+
),
725736
context={
726737
"reason": "empty_stdout",
727738
"exit_code": result.exit_code,
@@ -743,8 +754,24 @@ async def _validate_remote_path_access(
743754
)
744755
if result.exit_code == 113:
745756
raise ValueError(result.stderr.decode("utf-8", errors="replace").strip())
757+
if result.exit_code == 114:
758+
stderr = result.stderr.decode("utf-8", errors="replace")
759+
context: dict[str, object] = {"reason": "read_only_extra_path_grant"}
760+
for line in stderr.splitlines():
761+
if line.startswith("read-only extra path grant: "):
762+
context["grant_path"] = line.removeprefix("read-only extra path grant: ")
763+
elif line.startswith("resolved path: "):
764+
context["resolved_path"] = line.removeprefix("resolved path: ")
765+
raise WorkspaceArchiveWriteError(path=workspace_path, context=context)
746766
raise ExecNonZeroError(
747-
result, command=("resolve_workspace_path", str(root), str(workspace_path), *extra_roots)
767+
result,
768+
command=(
769+
"resolve_workspace_path",
770+
str(root),
771+
str(workspace_path),
772+
"1" if for_write else "0",
773+
*extra_grant_args,
774+
),
748775
)
749776

750777
@abc.abstractmethod

src/agents/sandbox/session/runtime_helpers.py

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,23 @@
1515
1616
root="$1"
1717
candidate="$2"
18-
shift 2
18+
for_write="$3"
19+
shift 3
1920
max_symlink_depth=64
2021
22+
case "$for_write" in
23+
0|1) ;;
24+
*)
25+
printf 'for_write must be 0 or 1: %s\\n' "$for_write" >&2
26+
exit 64
27+
;;
28+
esac
29+
30+
if [ $(( $# % 2 )) -ne 0 ]; then
31+
printf 'extra path grants must be root/read_only pairs\\n' >&2
32+
exit 64
33+
fi
34+
2135
resolve_path() {
2236
path="$1"
2337
depth="${2:-0}"
@@ -69,6 +83,10 @@
6983
}
7084
7185
resolved_candidate=$(resolve_path "$candidate" 0)
86+
best_grant_root=""
87+
best_grant_original=""
88+
best_grant_read_only="0"
89+
best_grant_len=0
7290
7391
check_root() {
7492
allowed_root="$1"
@@ -90,14 +108,47 @@
90108
fi
91109
}
92110
93-
for extra_root in "$@"; do
94-
reject_root_grant "$extra_root"
111+
consider_extra_grant() {
112+
allowed_root="$1"
113+
read_only="$2"
114+
case "$read_only" in
115+
0|1) ;;
116+
*)
117+
printf 'extra path grant read_only must be 0 or 1: %s\\n' "$read_only" >&2
118+
exit 64
119+
;;
120+
esac
121+
122+
reject_root_grant "$allowed_root"
123+
resolved_root=$(resolve_path "$allowed_root" 0)
124+
case "$resolved_candidate" in
125+
"$resolved_root"|"$resolved_root"/*)
126+
root_len=${#resolved_root}
127+
if [ "$root_len" -gt "$best_grant_len" ]; then
128+
best_grant_root="$resolved_root"
129+
best_grant_original="$allowed_root"
130+
best_grant_read_only="$read_only"
131+
best_grant_len="$root_len"
132+
fi
133+
;;
134+
esac
135+
}
136+
137+
while [ "$#" -gt 0 ]; do
138+
consider_extra_grant "$1" "$2"
139+
shift 2
95140
done
96141
97142
check_root "$root"
98-
for extra_root in "$@"; do
99-
check_root "$extra_root"
100-
done
143+
if [ -n "$best_grant_root" ]; then
144+
if [ "$for_write" = "1" ] && [ "$best_grant_read_only" = "1" ]; then
145+
printf 'read-only extra path grant: %s\\nresolved path: %s\\n' \
146+
"$best_grant_original" "$resolved_candidate" >&2
147+
exit 114
148+
fi
149+
printf '%s\\n' "$resolved_candidate"
150+
exit 0
151+
fi
101152
102153
printf 'workspace escape: %s\\n' "$resolved_candidate" >&2
103154
exit 111

src/agents/sandbox/workspace_paths.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -159,17 +159,15 @@ def _raise_if_read_only_grant(
159159
},
160160
)
161161

162-
def extra_path_grant_roots(self, *, writable_only: bool = False) -> tuple[Path, ...]:
163-
"""Return normalized extra grant roots for remote realpath checks."""
162+
def extra_path_grant_rules(self) -> tuple[tuple[Path, bool], ...]:
163+
"""Return normalized extra grant roots and access modes for remote realpath checks."""
164164

165-
roots: list[Path] = []
165+
rules: list[tuple[Path, bool]] = []
166166
for grant in self._extra_path_grants:
167-
if writable_only and grant.read_only:
168-
continue
169167
root = Path(grant.path)
170168
_raise_if_filesystem_root(root)
171-
roots.append(root)
172-
return tuple(roots)
169+
rules.append((root, grant.read_only))
170+
return tuple(rules)
173171

174172
def _absolute_workspace_posix_path(self, path: Path) -> PurePosixPath:
175173
normalized = self._absolute_posix_path(path)

tests/extensions/test_sandbox_e2b.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ async def kill(self) -> None:
301301
if _is_helper_present_command(command):
302302
return _FakeE2BResult()
303303
if parts and parts[0] == str(RESOLVE_WORKSPACE_PATH_HELPER.install_path):
304-
return _FakeE2BResult(stdout=parts[-1])
304+
return _FakeE2BResult(stdout=parts[2])
305305
if parts and parts[0] == str(WORKSPACE_FINGERPRINT_HELPER.install_path):
306306
return _FakeE2BResult(
307307
stdout='{"fingerprint":"fake-workspace-fingerprint","version":"workspace_tar_sha256_v1"}\n'

tests/extensions/test_sandbox_modal.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ def __init__(self, payload: bytes = b"") -> None:
227227
wait=_with_aio(lambda: 0),
228228
)
229229
if command and command[0] == resolve_helper_path:
230-
stdout = str(command[-1]).encode("utf-8")
230+
stdout = str(command[2]).encode("utf-8")
231231
if command and command[0] == fingerprint_helper_path:
232232
stdout = (
233233
b'{"fingerprint":"fake-workspace-fingerprint",'
@@ -1758,7 +1758,7 @@ async def _fake_exec(
17581758
assert any(cmd and cmd[0] == helper_path for cmd in second_run_commands)
17591759
assert commands == [
17601760
["test", "-x", helper_path],
1761-
[helper_path, "/workspace", "/workspace/link.txt"],
1761+
[helper_path, "/workspace", "/workspace/link.txt", "0"],
17621762
]
17631763

17641764

tests/sandbox/test_docker.py

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -327,13 +327,57 @@ async def _exec_internal(
327327
if cmd == ["test", "-x", helper_path]:
328328
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
329329
if cmd and cmd[0] == helper_path:
330+
for_write = cmd[3]
330331
candidate = self._host_path(cmd[2]).resolve(strict=False)
331-
roots = [self._host_path(root).resolve(strict=False) for root in [cmd[1], *cmd[3:]]]
332-
for root in roots:
332+
workspace_root = self._host_path(cmd[1]).resolve(strict=False)
333+
try:
334+
candidate.relative_to(workspace_root)
335+
except ValueError:
336+
pass
337+
else:
338+
return ExecResult(
339+
stdout=str(self._container_path(candidate)).encode("utf-8"),
340+
stderr=b"",
341+
exit_code=0,
342+
)
343+
344+
best_root: Path | None = None
345+
best_original = ""
346+
best_read_only = False
347+
grant_args = cmd[4:]
348+
assert len(grant_args) % 2 == 0
349+
for original_root, read_only_text in zip(
350+
grant_args[::2],
351+
grant_args[1::2],
352+
strict=False,
353+
):
354+
root = self._host_path(original_root).resolve(strict=False)
355+
if root == root.parent:
356+
return ExecResult(
357+
stdout=b"",
358+
stderr=(
359+
f"extra path grant must not resolve to filesystem root: {original_root}"
360+
).encode(),
361+
exit_code=113,
362+
)
333363
try:
334364
candidate.relative_to(root)
335365
except ValueError:
336366
continue
367+
if best_root is None or len(root.parts) > len(best_root.parts):
368+
best_root = root
369+
best_original = original_root
370+
best_read_only = read_only_text == "1"
371+
if best_root is not None:
372+
if for_write == "1" and best_read_only:
373+
return ExecResult(
374+
stdout=b"",
375+
stderr=(
376+
f"read-only extra path grant: {best_original}\n"
377+
f"resolved path: {self._container_path(candidate)}\n"
378+
).encode(),
379+
exit_code=114,
380+
)
337381
return ExecResult(
338382
stdout=str(self._container_path(candidate)).encode("utf-8"),
339383
stderr=b"",
@@ -1006,14 +1050,56 @@ async def test_docker_write_rejects_workspace_symlink_to_read_only_extra_path_gr
10061050
),
10071051
)
10081052

1009-
with pytest.raises(InvalidManifestPathError) as exc_info:
1053+
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
10101054
await session.write(Path("tmp-link/result.txt"), io.BytesIO(b"scratch output"))
10111055

1012-
assert str(exc_info.value) == "manifest path must not escape root: tmp-link/result.txt"
1056+
assert str(exc_info.value) == "failed to write archive for path: /workspace/tmp-link/result.txt"
1057+
assert exc_info.value.context == {
1058+
"path": "/workspace/tmp-link/result.txt",
1059+
"reason": "read_only_extra_path_grant",
1060+
"grant_path": "/tmp",
1061+
"resolved_path": "/tmp/result.txt",
1062+
}
1063+
1064+
1065+
@pytest.mark.asyncio
1066+
async def test_docker_write_rejects_workspace_symlink_to_nested_read_only_extra_path_grant(
1067+
tmp_path: Path,
1068+
) -> None:
1069+
host_root = tmp_path / "container"
1070+
workspace = host_root / "workspace"
1071+
extra_root = host_root / "tmp"
1072+
protected_root = extra_root / "protected"
1073+
workspace.mkdir(parents=True)
1074+
protected_root.mkdir(parents=True)
1075+
(workspace / "tmp-link").symlink_to(extra_root, target_is_directory=True)
1076+
1077+
session = _HostBackedDockerSession(
1078+
host_root=host_root,
1079+
manifest=Manifest(
1080+
root="/workspace",
1081+
extra_path_grants=(
1082+
SandboxPathGrant(path="/tmp"),
1083+
SandboxPathGrant(path="/tmp/protected", read_only=True),
1084+
),
1085+
),
1086+
)
1087+
1088+
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
1089+
await session.write(
1090+
Path("tmp-link/protected/result.txt"),
1091+
io.BytesIO(b"scratch output"),
1092+
)
1093+
1094+
assert (
1095+
str(exc_info.value)
1096+
== "failed to write archive for path: /workspace/tmp-link/protected/result.txt"
1097+
)
10131098
assert exc_info.value.context == {
1014-
"rel": "tmp-link/result.txt",
1015-
"reason": "escape_root",
1016-
"resolved_path": "workspace escape",
1099+
"path": "/workspace/tmp-link/protected/result.txt",
1100+
"reason": "read_only_extra_path_grant",
1101+
"grant_path": "/tmp/protected",
1102+
"resolved_path": "/tmp/protected/result.txt",
10171103
}
10181104

10191105

tests/sandbox/test_runtime.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -422,15 +422,15 @@ async def test_remote_realpath_empty_success_output_is_transport_error() -> None
422422
await session._validate_remote_path_access("file.txt") # noqa: SLF001
423423

424424
assert exc_info.value.context == {
425-
"command": ("resolve_workspace_path", "/workspace", "/workspace/file.txt"),
426-
"command_str": "resolve_workspace_path /workspace /workspace/file.txt",
425+
"command": ("resolve_workspace_path", "/workspace", "/workspace/file.txt", "0"),
426+
"command_str": "resolve_workspace_path /workspace /workspace/file.txt 0",
427427
"reason": "empty_stdout",
428428
"exit_code": 0,
429429
"stdout": "",
430430
"stderr": "",
431431
}
432432
assert session.exec_commands == [
433-
("/tmp/resolve_workspace_path", "/workspace", "/workspace/file.txt")
433+
("/tmp/resolve_workspace_path", "/workspace", "/workspace/file.txt", "0")
434434
]
435435

436436

@@ -3843,6 +3843,43 @@ def test_unix_local_darwin_exec_profile_allows_extra_path_grants(tmp_path: Path)
38433843
assert f'(allow file-write* (subpath "{read_only_root}"))' not in profile_lines
38443844

38453845

3846+
def test_unix_local_darwin_exec_profile_denies_nested_read_only_extra_path_grant(
3847+
tmp_path: Path,
3848+
) -> None:
3849+
workspace_root = tmp_path / "workspace"
3850+
read_write_root = tmp_path / "read-write"
3851+
read_only_root = read_write_root / "protected"
3852+
workspace_root.mkdir()
3853+
read_only_root.mkdir(parents=True)
3854+
session = UnixLocalSandboxSession.from_state(
3855+
UnixLocalSandboxSessionState(
3856+
session_id=uuid.uuid4(),
3857+
manifest=Manifest(
3858+
root=str(workspace_root),
3859+
extra_path_grants=(
3860+
SandboxPathGrant(path=str(read_write_root)),
3861+
SandboxPathGrant(path=str(read_only_root), read_only=True),
3862+
),
3863+
),
3864+
snapshot=NoopSnapshot(id="darwin-nested-extra-path-grant"),
3865+
workspace_root_owned=False,
3866+
)
3867+
)
3868+
3869+
profile = session._darwin_exec_profile(
3870+
workspace_root,
3871+
extra_path_grants=session._darwin_extra_path_grant_roots(),
3872+
)
3873+
profile_lines = profile.splitlines()
3874+
parent_write_allow = f'(allow file-write* (subpath "{read_write_root}"))'
3875+
child_write_deny = f'(deny file-write* (subpath "{read_only_root}"))'
3876+
3877+
assert parent_write_allow in profile_lines
3878+
assert child_write_deny in profile_lines
3879+
assert profile_lines.index(parent_write_allow) < profile_lines.index(child_write_deny)
3880+
assert f'(allow file-write* (subpath "{read_only_root}"))' not in profile_lines
3881+
3882+
38463883
def test_unix_local_darwin_exec_profile_rejects_extra_path_grant_symlink_to_root(
38473884
tmp_path: Path,
38483885
) -> None:

0 commit comments

Comments
 (0)