Add bounded stdin to owner execution
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a06def-6490-7033-8448-2eab2d12ed44
This commit is contained in:
parent
fd9297810c
commit
b6655d8859
15 changed files with 74 additions and 17 deletions
|
|
@ -19,11 +19,13 @@ Optional (checkpoints): `supports_snapshots()`, `snapshot(handle)`,
|
|||
|
||||
Optional (owner-mediated execution): `supports_execution()`, then
|
||||
`execute(handle, command, credential_route_refs, execution_context,
|
||||
timeout_seconds, max_output_bytes)`. The default implementation fails closed.
|
||||
timeout_seconds, max_output_bytes, stdin_text)`. The default implementation fails closed.
|
||||
`ext.bwrap` is the reference implementation. An executing extension must
|
||||
validate that its workspace belongs to the exact sandbox handle, sanitize the
|
||||
child environment, use an argument vector, enforce the requested bounds, and
|
||||
must not fall back to a host workspace.
|
||||
Bounded stdin is content-bearing and must never be included in lifecycle logs or
|
||||
execution evidence.
|
||||
|
||||
### Base class
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,20 @@ identity mismatch, non-ready or expired state, concurrent execution, and every
|
|||
extension without an owner execution implementation. It never retries against
|
||||
the host source checkout.
|
||||
|
||||
For the local owner transport, retain these values from gateway resolution:
|
||||
|
||||
| sand-boxer field | Glas source |
|
||||
|---|---|
|
||||
| `consumer.actor` | `ExecutionRequest.actor` |
|
||||
| `consumer.project` | `ExecutionRequest.project` |
|
||||
| `consumer.run_id` | resolved `request_id` |
|
||||
| `credential_route_refs` | exact selected `HarnessProfile.credential_route_refs` |
|
||||
| `timeout_seconds` | selected profile limit |
|
||||
|
||||
Use bounded `stdin_text` with an in-sandbox writer command to create the private
|
||||
task file under `.git`; sand-boxer does not echo stdin in the result or lifecycle
|
||||
evidence. Local transport must not write the task file through host `Path` APIs.
|
||||
|
||||
## Ownership
|
||||
|
||||
| Concern | Owner |
|
||||
|
|
|
|||
|
|
@ -225,6 +225,11 @@ sand-boxer commits to:
|
|||
workspace validation, sanitized environment, bounded duration/output, and no
|
||||
host-checkout or alternate-extension fallback
|
||||
|
||||
Owner execution also accepts up to 1,000,000 UTF-8 bytes of `stdin_text`. It is
|
||||
delivered only to the child process and excluded from results and lifecycle
|
||||
events, allowing a harness to create prompt/task files inside the sandbox
|
||||
without putting their content in argv or writing through the host workspace.
|
||||
|
||||
sand-boxer does **not** provide intent-aware egress filtering in v1.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ Call `POST /v1/sandboxes/SANDBOX_ID/exec` with a bearer token and this body:
|
|||
"run_id": "run-456"
|
||||
},
|
||||
"credential_route_refs": [],
|
||||
"stdin_text": null,
|
||||
"timeout_seconds": 30,
|
||||
"max_output_bytes": 65536
|
||||
}
|
||||
|
|
@ -68,6 +69,7 @@ Call `POST /v1/sandboxes/SANDBOX_ID/exec` with a bearer token and this body:
|
|||
|
||||
The bearer authenticates access to the owner service; exact consumer identity
|
||||
matching additionally binds the command to the existing sandbox grant.
|
||||
`stdin_text` is capped at 1,000,000 UTF-8 bytes and is not returned or logged.
|
||||
|
||||
## Destroy
|
||||
|
||||
|
|
|
|||
|
|
@ -49,13 +49,14 @@ def main() -> int:
|
|||
created.raise_for_status()
|
||||
sandbox_id = created.json()["sandbox_id"]
|
||||
code = (
|
||||
"import json, os; from pathlib import Path; "
|
||||
"import json, os, sys; from pathlib import Path; task=sys.stdin.read(); "
|
||||
f"outside=Path({str(outside)!r}).exists(); "
|
||||
"interfaces=[line.split(':',1)[0].strip() for line in "
|
||||
"Path('/proc/net/dev').read_text().splitlines()[2:]]; "
|
||||
"print(json.dumps({'cwd':str(Path.cwd()), 'copied':Path('copied.txt').is_file(), "
|
||||
"'host_source_visible':outside, 'network_interfaces':interfaces, "
|
||||
"'run_id':os.environ.get('SANDBOXER_RUN_ID')}))"
|
||||
"'run_id':os.environ.get('SANDBOXER_RUN_ID'), "
|
||||
"'stdin_bytes':len(task.encode())}))"
|
||||
)
|
||||
executed = client.post(
|
||||
f"/v1/sandboxes/{sandbox_id}/exec",
|
||||
|
|
@ -63,6 +64,7 @@ def main() -> int:
|
|||
json={
|
||||
"command": ["/usr/bin/python3", "-c", code],
|
||||
"consumer": consumer,
|
||||
"stdin_text": '{"title":"non-secret API smoke"}',
|
||||
"timeout_seconds": 30,
|
||||
"max_output_bytes": 65_536,
|
||||
},
|
||||
|
|
@ -95,6 +97,8 @@ def main() -> int:
|
|||
and proof["copied"]
|
||||
and not proof["host_source_visible"]
|
||||
and proof["network_interfaces"] == ["lo"]
|
||||
and proof["stdin_bytes"] == 32
|
||||
and "stdin_text" not in result
|
||||
and payload["teardown"]["workspace_removed"]
|
||||
)
|
||||
return 0 if passed else 1
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
|
@ -157,6 +158,9 @@ def sandbox_exec(
|
|||
max_output_bytes: Annotated[
|
||||
int, typer.Option(help="Per-stream captured output limit")
|
||||
] = 262_144,
|
||||
stdin: Annotated[
|
||||
bool, typer.Option("--stdin", help="Read bounded command stdin from this process")
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Run COMMAND inside a bwrap sandbox through its owning process."""
|
||||
command = list(ctx.args)
|
||||
|
|
@ -164,19 +168,20 @@ def sandbox_exec(
|
|||
command = command[1:]
|
||||
if not command:
|
||||
raise typer.BadParameter("COMMAND is required after --")
|
||||
request = SandboxExecRequest(
|
||||
command=command,
|
||||
consumer=Consumer(
|
||||
actor=ActorType(actor),
|
||||
project=project,
|
||||
session_id=session_id,
|
||||
run_id=run_id,
|
||||
),
|
||||
credential_route_refs=credential_route_ref or [],
|
||||
timeout_seconds=timeout,
|
||||
max_output_bytes=max_output_bytes,
|
||||
)
|
||||
try:
|
||||
request = SandboxExecRequest(
|
||||
command=command,
|
||||
consumer=Consumer(
|
||||
actor=ActorType(actor),
|
||||
project=project,
|
||||
session_id=session_id,
|
||||
run_id=run_id,
|
||||
),
|
||||
credential_route_refs=credential_route_ref or [],
|
||||
timeout_seconds=timeout,
|
||||
max_output_bytes=max_output_bytes,
|
||||
stdin_text=sys.stdin.read(1_000_001) if stdin else None,
|
||||
)
|
||||
result = SandboxManager().execute(sandbox_id, request)
|
||||
except (KeyError, PermissionError, RuntimeError, ValueError) as exc:
|
||||
typer.echo(f"Error: {exc}", err=True)
|
||||
|
|
|
|||
|
|
@ -231,6 +231,8 @@ class SandboxManager:
|
|||
command_size = sum(len(arg.encode("utf-8")) for arg in request.command)
|
||||
if command_size > 65_536:
|
||||
raise ValueError("command argument vector exceeds 65536 bytes")
|
||||
if request.stdin_text is not None and len(request.stdin_text.encode("utf-8")) > 1_000_000:
|
||||
raise ValueError("command stdin exceeds 1000000 bytes")
|
||||
invalid_refs = [
|
||||
ref
|
||||
for ref in request.credential_route_refs
|
||||
|
|
@ -304,6 +306,7 @@ class SandboxManager:
|
|||
execution_context=context,
|
||||
timeout_seconds=request.timeout_seconds,
|
||||
max_output_bytes=request.max_output_bytes,
|
||||
stdin_text=request.stdin_text,
|
||||
)
|
||||
except Exception as exc:
|
||||
status.state = SandboxState.READY
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ class SandboxExtension(ABC):
|
|||
execution_context: dict[str, str],
|
||||
timeout_seconds: int,
|
||||
max_output_bytes: int,
|
||||
stdin_text: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Run a bounded command through an owner-mediated sandbox boundary."""
|
||||
raise NotImplementedError(f"{type(self).__name__} does not support execution")
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ class BwrapExtension(SandboxExtension):
|
|||
execution_context: dict[str, str],
|
||||
timeout_seconds: int,
|
||||
max_output_bytes: int,
|
||||
stdin_text: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Ask the broker already inside bwrap to run an argument-vector command."""
|
||||
pid = int(handle.get("pid", "0"))
|
||||
|
|
@ -197,6 +198,7 @@ class BwrapExtension(SandboxExtension):
|
|||
"execution_context": execution_context,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"max_output_bytes": max_output_bytes,
|
||||
"stdin_text": stdin_text,
|
||||
}
|
||||
response_limit = max_output_bytes * 2 + 65_536
|
||||
chunks: list[bytes] = []
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import subprocess
|
|||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_MAX_REQUEST_BYTES = 1_048_576
|
||||
# A validated 1 MB stdin can expand sixfold when JSON escapes control bytes;
|
||||
# leave bounded headroom for the argv, route references, and execution context.
|
||||
_MAX_REQUEST_BYTES = 7_000_000
|
||||
|
||||
|
||||
def _bounded_output(value: bytes | None, limit: int) -> tuple[str, bool]:
|
||||
|
|
@ -27,6 +29,7 @@ def _run(payload: dict, workspace: Path) -> dict[str, object]:
|
|||
max_output_bytes = int(payload["max_output_bytes"])
|
||||
credential_refs = payload.get("credential_route_refs", [])
|
||||
context = payload.get("execution_context", {})
|
||||
stdin_text = payload.get("stdin_text")
|
||||
child_env = {
|
||||
"HOME": str(workspace),
|
||||
"LANG": "C.UTF-8",
|
||||
|
|
@ -39,12 +42,16 @@ def _run(payload: dict, workspace: Path) -> dict[str, object]:
|
|||
command,
|
||||
cwd=workspace,
|
||||
env=child_env,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
try:
|
||||
stdout_raw, stderr_raw = process.communicate(timeout=timeout_seconds)
|
||||
stdin_bytes = stdin_text.encode("utf-8") if stdin_text is not None else None
|
||||
stdout_raw, stderr_raw = process.communicate(
|
||||
input=stdin_bytes, timeout=timeout_seconds
|
||||
)
|
||||
exit_code = process.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ class ExtensionBackend(Protocol):
|
|||
execution_context: dict[str, str],
|
||||
timeout_seconds: int,
|
||||
max_output_bytes: int,
|
||||
stdin_text: str | None = None,
|
||||
) -> dict[str, object]: ...
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ class SandboxExecRequest(BaseModel):
|
|||
command: list[str] = Field(min_length=1, max_length=256)
|
||||
consumer: Consumer
|
||||
credential_route_refs: list[str] = Field(default_factory=list, max_length=32)
|
||||
stdin_text: str | None = Field(default=None, max_length=1_000_000)
|
||||
timeout_seconds: int = Field(default=900, ge=1, le=3600)
|
||||
max_output_bytes: int = Field(default=262_144, ge=1, le=1_048_576)
|
||||
|
||||
|
|
|
|||
|
|
@ -255,6 +255,7 @@ def test_execute_sends_bounded_request_to_in_namespace_owner(tmp_path) -> None:
|
|||
execution_context={"actor": "agt", "run_id": "run-1"},
|
||||
timeout_seconds=30,
|
||||
max_output_bytes=1024,
|
||||
stdin_text="task payload",
|
||||
)
|
||||
thread.join(timeout=2)
|
||||
server.close()
|
||||
|
|
@ -264,6 +265,7 @@ def test_execute_sends_bounded_request_to_in_namespace_owner(tmp_path) -> None:
|
|||
assert received["credential_route_refs"] == ["rein-openweights-openrouter-approle"]
|
||||
assert received["timeout_seconds"] == 30
|
||||
assert received["max_output_bytes"] == 1024
|
||||
assert received["stdin_text"] == "task payload"
|
||||
assert result["stdout"] == "ok\n"
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
|
|
@ -278,6 +280,7 @@ def test_in_namespace_runner_uses_sanitized_environment(tmp_path) -> None:
|
|||
"execution_context": {"actor": "agt", "run_id": "run-1"},
|
||||
"timeout_seconds": 30,
|
||||
"max_output_bytes": 1024,
|
||||
"stdin_text": "task payload",
|
||||
}
|
||||
with patch(
|
||||
"sandboxer.extensions.bwrap_runner.subprocess.Popen", return_value=process
|
||||
|
|
@ -286,6 +289,7 @@ def test_in_namespace_runner_uses_sanitized_environment(tmp_path) -> None:
|
|||
|
||||
assert popen.call_args.args[0] == ["python3", "-V"]
|
||||
assert popen.call_args.kwargs["cwd"] == tmp_path
|
||||
process.communicate.assert_called_once_with(input=b"task payload", timeout=30)
|
||||
child_env = popen.call_args.kwargs["env"]
|
||||
assert child_env["SANDBOXER_ACTOR"] == "agt"
|
||||
assert child_env["SANDBOXER_RUN_ID"] == "run-1"
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ def _exec_request(**consumer_overrides) -> SandboxExecRequest:
|
|||
command=["python3", "-V"],
|
||||
consumer=Consumer.model_validate(consumer),
|
||||
credential_route_refs=["rein-openweights-openrouter-approle"],
|
||||
stdin_text='{"title":"bounded task"}',
|
||||
timeout_seconds=30,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,11 @@ The authenticated HTTP smoke subsequently exposed and fixed a lifecycle defect:
|
|||
could disappear before a later API `exec`. Owner sandboxes now persist across
|
||||
requests and remain bounded by explicit destroy, TTL expiry, and stale reaping.
|
||||
|
||||
The consumer-contract audit also found that Glas needs to create a private task
|
||||
file without writing through the host mirror or putting prompt content in argv.
|
||||
Owner exec now supports bounded `stdin_text` (1,000,000 UTF-8 bytes), delivers it
|
||||
only to the child process, and excludes it from results and lifecycle evidence.
|
||||
|
||||
## Prove one governed rein and coordinate consumers
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue