Add bounded stdin to owner execution
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a06def-6490-7033-8448-2eab2d12ed44
This commit is contained in:
tegwick 2026-09-04 22:30:11 +02:00
parent fd9297810c
commit b6655d8859
15 changed files with 74 additions and 17 deletions

View file

@ -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

View file

@ -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 |

View file

@ -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.
---

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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")

View file

@ -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] = []

View file

@ -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

View file

@ -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]: ...

View file

@ -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)

View file

@ -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"

View file

@ -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,
)

View file

@ -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