Skip to content

Commit a436416

Browse files
committed
add extra_path_grants for manifest
1 parent dd3f59e commit a436416

27 files changed

Lines changed: 1124 additions & 239 deletions

docs/sandbox/guide.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ Prefer built-in capabilities when they fit. Write a custom capability only when
216216

217217
### Manifest
218218

219-
A [`Manifest`][agents.sandbox.manifest.Manifest] describes the workspace for a fresh sandbox session. It can set the workspace `root`, declare files and directories, copy in local files, clone Git repos, attach remote storage mounts, set environment variables, and define users or groups.
219+
A [`Manifest`][agents.sandbox.manifest.Manifest] describes the workspace for a fresh sandbox session. It can set the workspace `root`, declare files and directories, copy in local files, clone Git repos, attach remote storage mounts, set environment variables, define users or groups, and grant access to specific absolute paths outside the workspace.
220220

221221
Manifest entry paths are workspace-relative. They cannot be absolute paths or escape the workspace with `..`, which keeps the workspace contract portable across local, Docker, and hosted clients.
222222

@@ -237,6 +237,21 @@ Mount entries describe what storage to expose; mount strategies describe how a s
237237

238238
Good manifest design usually means keeping the workspace contract narrow, putting long task recipes in workspace files such as `repo/task.md`, and using relative workspace paths in instructions, for example `repo/task.md` or `output/report.md`. If the agent edits files with the `Filesystem` capability's `apply_patch` tool, remember that patch paths are relative to the sandbox workspace root, not the shell `workdir`.
239239

240+
Use `extra_path_grants` only when the agent needs a concrete absolute path outside the workspace, such as `/tmp` for temporary tool output or `/opt/toolchain` for a read-only runtime. A grant applies to both SDK file APIs and shell execution where the backend can enforce filesystem policy:
241+
242+
```python
243+
from agents.sandbox import Manifest, SandboxPathGrant
244+
245+
manifest = Manifest(
246+
extra_path_grants=(
247+
SandboxPathGrant(path="/tmp"),
248+
SandboxPathGrant(path="/opt/toolchain", read_only=True),
249+
),
250+
)
251+
```
252+
253+
Snapshots and `persist_workspace()` still include only the workspace root. Extra granted paths are runtime access, not durable workspace state.
254+
240255
### Permissions
241256

242257
`Permissions` controls filesystem permissions for manifest entries. It is about the files the sandbox materializes, not model permissions, approval policy, or API credentials.

examples/sandbox/unix_local_runner.py

Lines changed: 144 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@
77

88
import argparse
99
import asyncio
10+
import io
1011
import sys
12+
import tempfile
1113
from pathlib import Path
1214

1315
from openai.types.responses import ResponseTextDeltaEvent
1416

1517
from agents import Runner
1618
from agents.run import RunConfig
17-
from agents.sandbox import SandboxAgent, SandboxRunConfig
19+
from agents.sandbox import Manifest, SandboxAgent, SandboxPathGrant, SandboxRunConfig
20+
from agents.sandbox.errors import WorkspaceArchiveWriteError
1821
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
1922

2023
if __package__ is None or __package__ == "":
@@ -29,9 +32,9 @@
2932
)
3033

3134

32-
async def main(model: str, question: str, stream: bool) -> None:
35+
def _build_manifest(external_dir: Path, scratch_dir: Path) -> Manifest:
3336
# The manifest is the file tree that will be materialized into the sandbox workspace.
34-
manifest = text_manifest(
37+
return text_manifest(
3538
{
3639
"account_brief.md": (
3740
"# Northwind Health\n\n"
@@ -57,54 +60,155 @@ async def main(model: str, question: str, stream: bool) -> None:
5760
"- Customer procurement requires final legal language by April 1.\n"
5861
),
5962
}
63+
).model_copy(
64+
update={
65+
"extra_path_grants": (
66+
SandboxPathGrant(
67+
path=str(external_dir),
68+
read_only=True,
69+
description="read-only external renewal packet notes",
70+
),
71+
SandboxPathGrant(
72+
path=str(scratch_dir),
73+
description="temporary renewal packet scratch files",
74+
),
75+
)
76+
},
77+
deep=True,
6078
)
6179

62-
# The sandbox agent sees the manifest as its workspace and uses one shared shell tool
63-
# to inspect the files before answering.
64-
agent = SandboxAgent(
65-
name="Renewal Packet Analyst",
66-
model=model,
67-
instructions=(
68-
"You review renewal packets for an account team. Inspect the packet before answering. "
69-
"Keep the response concise, business-focused, and cite the file names that support "
70-
"each conclusion. "
71-
"If a conclusion depends on a file, mention that file by name. Do not invent numbers "
72-
"or statuses that are not present in the workspace."
73-
),
74-
default_manifest=manifest,
75-
capabilities=[WorkspaceShellCapability()],
76-
)
77-
78-
# With Unix-local sandboxes, the runner creates and cleans up the temporary workspace for us.
79-
run_config = RunConfig(
80-
sandbox=SandboxRunConfig(client=UnixLocalSandboxClient()),
81-
workflow_name="Unix local sandbox review",
82-
)
8380

84-
if not stream:
85-
result = await Runner.run(agent, question, run_config=run_config)
86-
print(result.final_output)
87-
return
81+
async def _verify_extra_path_grants() -> None:
82+
with tempfile.TemporaryDirectory(prefix="agents-unix-local-extra-") as extra_root_text:
83+
extra_root = Path(extra_root_text)
84+
external_dir = extra_root / "external"
85+
scratch_dir = extra_root / "scratch"
86+
external_dir.mkdir()
87+
scratch_dir.mkdir()
88+
external_input = external_dir / "external_input.txt"
89+
read_only_output = external_dir / "blocked.txt"
90+
sdk_output = scratch_dir / "sdk_output.txt"
91+
exec_output = scratch_dir / "exec_output.txt"
92+
external_input.write_text("external grant input\n", encoding="utf-8")
93+
94+
client = UnixLocalSandboxClient()
95+
sandbox = await client.create(manifest=_build_manifest(external_dir, scratch_dir))
96+
try:
97+
async with sandbox:
98+
payload = await sandbox.read(external_input)
99+
try:
100+
await sandbox.write(read_only_output, io.BytesIO(b"should fail\n"))
101+
except WorkspaceArchiveWriteError:
102+
pass
103+
else:
104+
raise RuntimeError(
105+
"SDK write to read-only extra path grant unexpectedly worked."
106+
)
107+
await sandbox.write(sdk_output, io.BytesIO(b"sdk grant output\n"))
108+
exec_result = await sandbox.exec(
109+
"sh",
110+
"-c",
111+
'cat "$1"; printf "%s\\n" "exec grant output" > "$2"',
112+
"sh",
113+
external_input,
114+
exec_output,
115+
shell=False,
116+
)
117+
118+
if payload.read() != b"external grant input\n":
119+
raise RuntimeError(
120+
"SDK read from extra path grant returned unexpected content."
121+
)
122+
if sdk_output.read_text(encoding="utf-8") != "sdk grant output\n":
123+
raise RuntimeError("SDK write to extra path grant failed.")
124+
if exec_result.stdout != b"external grant input\n" or exec_result.exit_code != 0:
125+
raise RuntimeError("Shell read from extra path grant failed.")
126+
if exec_output.read_text(encoding="utf-8") != "exec grant output\n":
127+
raise RuntimeError("Shell write to extra path grant failed.")
128+
finally:
129+
await client.delete(sandbox)
130+
131+
print("extra_path_grants verification passed")
88132

89-
# The streaming path prints text deltas as they arrive so the example behaves like a demo.
90-
stream_result = Runner.run_streamed(agent, question, run_config=run_config)
91-
saw_text_delta = False
92-
async for event in stream_result.stream_events():
93-
if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent):
94-
if not saw_text_delta:
95-
print("assistant> ", end="", flush=True)
96-
saw_text_delta = True
97-
print(event.data.delta, end="", flush=True)
98133

99-
if saw_text_delta:
100-
print()
134+
async def main(model: str, question: str, stream: bool) -> None:
135+
with tempfile.TemporaryDirectory(prefix="agents-unix-local-extra-") as extra_root_text:
136+
extra_root = Path(extra_root_text)
137+
external_dir = extra_root / "external"
138+
scratch_dir = extra_root / "scratch"
139+
external_dir.mkdir()
140+
scratch_dir.mkdir()
141+
external_note = external_dir / "external_renewal_note.md"
142+
scratch_note = scratch_dir / "scratch_summary.md"
143+
external_note.write_text(
144+
"# External renewal note\n\n"
145+
"Finance approved discount authority up to 10 percent, but anything higher needs "
146+
"CFO approval before legal can finalize terms.\n",
147+
encoding="utf-8",
148+
)
149+
manifest = _build_manifest(external_dir, scratch_dir)
150+
151+
# The sandbox agent sees the manifest as its workspace and uses one shared shell tool
152+
# to inspect the files before answering.
153+
agent = SandboxAgent(
154+
name="Renewal Packet Analyst",
155+
model=model,
156+
instructions=(
157+
"You review renewal packets for an account team. Inspect the packet before "
158+
"answering. Keep the response concise, business-focused, and cite the file names "
159+
"that support each conclusion. If a conclusion depends on a file, mention that "
160+
"file by name. Do not invent numbers or statuses that are not present in the "
161+
"workspace. The manifest also grants read-only access to an external note at "
162+
f"`{external_note}` and read-write access to a scratch directory at "
163+
f"`{scratch_dir}`. Read the external note before answering, and write a brief "
164+
f"scratch note to `{scratch_note}`."
165+
),
166+
default_manifest=manifest,
167+
capabilities=[WorkspaceShellCapability()],
168+
)
169+
170+
# With Unix-local sandboxes, the runner creates and cleans up the temporary workspace for us.
171+
run_config = RunConfig(
172+
sandbox=SandboxRunConfig(client=UnixLocalSandboxClient()),
173+
workflow_name="Unix local sandbox review",
174+
tracing_disabled=True,
175+
)
176+
177+
if not stream:
178+
result = await Runner.run(agent, question, run_config=run_config)
179+
print(result.final_output)
180+
return
181+
182+
# The streaming path prints text deltas as they arrive so the example behaves like a demo.
183+
stream_result = Runner.run_streamed(agent, question, run_config=run_config)
184+
saw_text_delta = False
185+
async for event in stream_result.stream_events():
186+
if event.type == "raw_response_event" and isinstance(
187+
event.data, ResponseTextDeltaEvent
188+
):
189+
if not saw_text_delta:
190+
print("assistant> ", end="", flush=True)
191+
saw_text_delta = True
192+
print(event.data.delta, end="", flush=True)
193+
194+
if saw_text_delta:
195+
print()
101196

102197

103198
if __name__ == "__main__":
104199
parser = argparse.ArgumentParser()
105200
parser.add_argument("--model", default="gpt-5.4", help="Model name to use.")
106201
parser.add_argument("--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.")
107202
parser.add_argument("--stream", action="store_true", default=False, help="Stream the response.")
203+
parser.add_argument(
204+
"--verify-extra-path-grants",
205+
action="store_true",
206+
default=False,
207+
help="Run a local extra_path_grants smoke test without calling a model.",
208+
)
108209
args = parser.parse_args()
109210

110-
asyncio.run(main(args.model, args.question, args.stream))
211+
if args.verify_extra_path_grants:
212+
asyncio.run(_verify_extra_path_grants())
213+
else:
214+
asyncio.run(main(args.model, args.question, args.stream))

src/agents/extensions/sandbox/blaxel/sandbox.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ async def mkdir(
357357
if user is not None:
358358
path = await self._check_mkdir_with_exec(path, parents=parents, user=user)
359359
else:
360-
path = self.normalize_path(path)
360+
path = self.normalize_path(path, for_write=True)
361361
if path == Path("/"):
362362
return
363363
try:
@@ -409,7 +409,7 @@ async def write(
409409
if not isinstance(payload, bytes | bytearray):
410410
raise WorkspaceWriteTypeError(path=path, actual_type=type(payload).__name__)
411411

412-
workspace_path = self.normalize_path(path)
412+
workspace_path = self.normalize_path(path, for_write=True)
413413
try:
414414
await self._sandbox.fs.write_binary(str(workspace_path), bytes(payload))
415415
except Exception as e:

src/agents/extensions/sandbox/cloudflare/sandbox.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -319,8 +319,8 @@ def _runtime_helpers(self) -> tuple[RuntimeHelperScript, ...]:
319319
def _current_runtime_helper_cache_key(self) -> object | None:
320320
return self.state.sandbox_id
321321

322-
async def _normalize_path_for_io(self, path: Path | str) -> Path:
323-
return await self._normalize_path_for_remote_io(path)
322+
async def _validate_path_access(self, path: Path | str, *, for_write: bool = False) -> Path:
323+
return await self._validate_remote_path_access(path, for_write=for_write)
324324

325325
async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
326326
"""Cloudflare sandboxes do not yet support exposed port resolution."""
@@ -969,7 +969,7 @@ async def read(self, path: Path | str, *, user: str | User | None = None) -> io.
969969
if user is not None:
970970
await self._check_read_with_exec(path, user=user)
971971

972-
workspace_path = await self._normalize_path_for_io(path)
972+
workspace_path = await self._validate_path_access(path)
973973
http = self._session()
974974
url_path = quote(str(workspace_path).lstrip("/"), safe="/")
975975
url = self._url(f"file/{url_path}")
@@ -1040,7 +1040,7 @@ async def write(
10401040
raise WorkspaceWriteTypeError(path=path, actual_type=type(payload).__name__)
10411041

10421042
payload_bytes = bytes(payload)
1043-
workspace_path = await self._normalize_path_for_io(path)
1043+
workspace_path = await self._validate_path_access(path, for_write=True)
10441044

10451045
http = self._session()
10461046
url_path = quote(str(workspace_path).lstrip("/"), safe="/")

src/agents/extensions/sandbox/daytona/sandbox.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ async def mkdir(
364364
if user is not None:
365365
path = await self._check_mkdir_with_exec(path, parents=parents, user=user)
366366
else:
367-
path = self.normalize_path(path)
367+
path = self.normalize_path(path, for_write=True)
368368
if path == Path("/"):
369369
return
370370
try:
@@ -811,7 +811,7 @@ async def write(
811811
if not isinstance(payload, bytes | bytearray):
812812
raise WorkspaceWriteTypeError(path=path, actual_type=type(payload).__name__)
813813

814-
workspace_path = self.normalize_path(path)
814+
workspace_path = self.normalize_path(path, for_write=True)
815815
try:
816816
await self._sandbox.fs.upload_file(
817817
bytes(payload),

src/agents/extensions/sandbox/e2b/sandbox.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -700,8 +700,8 @@ async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
700700
)
701701
return endpoint
702702

703-
async def _normalize_path_for_io(self, path: Path | str) -> Path:
704-
return await self._normalize_path_for_remote_io(path)
703+
async def _validate_path_access(self, path: Path | str, *, for_write: bool = False) -> Path:
704+
return await self._validate_remote_path_access(path, for_write=for_write)
705705

706706
def _runtime_helpers(self) -> tuple[RuntimeHelperScript, ...]:
707707
return (RESOLVE_WORKSPACE_PATH_HELPER,)
@@ -1047,7 +1047,7 @@ async def read(self, path: Path, *, user: str | User | None = None) -> io.IOBase
10471047
if user is not None:
10481048
await self._check_read_with_exec(path, user=user)
10491049

1050-
workspace_path = await self._normalize_path_for_io(path)
1050+
workspace_path = await self._validate_path_access(path)
10511051

10521052
e2b_exc = _import_e2b_exceptions()
10531053
not_found_exc = e2b_exc.get("not_found")
@@ -1082,7 +1082,7 @@ async def write(
10821082
if not isinstance(payload, bytes | bytearray):
10831083
raise WorkspaceWriteTypeError(path=path, actual_type=type(payload).__name__)
10841084

1085-
workspace_path = await self._normalize_path_for_io(path)
1085+
workspace_path = await self._validate_path_access(path, for_write=True)
10861086

10871087
try:
10881088
await _sandbox_write_file(
@@ -1117,7 +1117,7 @@ async def mkdir(
11171117
if user is not None:
11181118
path = await self._check_mkdir_with_exec(path, parents=parents, user=user)
11191119
else:
1120-
path = await self._normalize_path_for_io(path)
1120+
path = await self._validate_path_access(path, for_write=True)
11211121

11221122
if user is None and not parents:
11231123
parent = path.parent

src/agents/extensions/sandbox/modal/sandbox.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -369,8 +369,8 @@ def __init__(
369369
self._modal_snapshot_ephemeral_backup = None
370370
self._modal_snapshot_ephemeral_backup_path = None
371371

372-
async def _normalize_path_for_io(self, path: Path | str) -> Path:
373-
return await self._normalize_path_for_remote_io(path)
372+
async def _validate_path_access(self, path: Path | str, *, for_write: bool = False) -> Path:
373+
return await self._validate_remote_path_access(path, for_write=for_write)
374374

375375
def _runtime_helpers(self) -> tuple[RuntimeHelperScript, ...]:
376376
return (RESOLVE_WORKSPACE_PATH_HELPER,)
@@ -1014,7 +1014,7 @@ async def read(self, path: Path, *, user: str | User | None = None) -> io.IOBase
10141014
await self._check_read_with_exec(path, user=user)
10151015

10161016
# Read by `cat` so the payload is returned as bytes.
1017-
workspace_path = await self._normalize_path_for_io(path)
1017+
workspace_path = await self._validate_path_access(path)
10181018
cmd = ["sh", "-lc", f"cat -- {shlex.quote(str(workspace_path))}"]
10191019
try:
10201020
out = await self.exec(*cmd, shell=False)
@@ -1049,7 +1049,7 @@ async def write(
10491049
await self._ensure_sandbox()
10501050
assert self._sandbox is not None
10511051

1052-
workspace_path = await self._normalize_path_for_io(path)
1052+
workspace_path = await self._validate_path_access(path, for_write=True)
10531053

10541054
async def _run_write() -> None:
10551055
assert self._sandbox is not None

0 commit comments

Comments
 (0)