From b6655d88590e03820675719d6dd574e5f5a9f82e Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 4 Sep 2026 22:30:11 +0200 Subject: [PATCH] Add bounded stdin to owner execution Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a06def-6490-7033-8448-2eab2d12ed44 --- docs/extension-sdk.md | 4 ++- docs/integrations/glas-harness.md | 14 +++++++++ docs/meta-framework.md | 5 ++++ docs/runbooks/profile-bwrap-local.md | 2 ++ scripts/smoke-bwrap-owner-api.py | 8 +++-- src/sandboxer/cli.py | 29 +++++++++++-------- src/sandboxer/core/manager.py | 3 ++ src/sandboxer/extensions/base.py | 1 + src/sandboxer/extensions/bwrap.py | 2 ++ src/sandboxer/extensions/bwrap_runner.py | 11 +++++-- src/sandboxer/extensions/registry.py | 1 + src/sandboxer/models.py | 1 + tests/test_bwrap.py | 4 +++ tests/test_manager.py | 1 + .../SAND-WP-0014-owner-mediated-execution.md | 5 ++++ 15 files changed, 74 insertions(+), 17 deletions(-) diff --git a/docs/extension-sdk.md b/docs/extension-sdk.md index e123b47..3acb0ad 100644 --- a/docs/extension-sdk.md +++ b/docs/extension-sdk.md @@ -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 diff --git a/docs/integrations/glas-harness.md b/docs/integrations/glas-harness.md index 6b8d464..e0b7d04 100644 --- a/docs/integrations/glas-harness.md +++ b/docs/integrations/glas-harness.md @@ -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 | diff --git a/docs/meta-framework.md b/docs/meta-framework.md index 1d51606..c92ac42 100644 --- a/docs/meta-framework.md +++ b/docs/meta-framework.md @@ -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. --- diff --git a/docs/runbooks/profile-bwrap-local.md b/docs/runbooks/profile-bwrap-local.md index b2993b6..d29edfd 100644 --- a/docs/runbooks/profile-bwrap-local.md +++ b/docs/runbooks/profile-bwrap-local.md @@ -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 diff --git a/scripts/smoke-bwrap-owner-api.py b/scripts/smoke-bwrap-owner-api.py index eea0f5e..dc69295 100644 --- a/scripts/smoke-bwrap-owner-api.py +++ b/scripts/smoke-bwrap-owner-api.py @@ -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 diff --git a/src/sandboxer/cli.py b/src/sandboxer/cli.py index a6ce904..d776cd2 100644 --- a/src/sandboxer/cli.py +++ b/src/sandboxer/cli.py @@ -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) diff --git a/src/sandboxer/core/manager.py b/src/sandboxer/core/manager.py index 9a01968..ad6ae79 100644 --- a/src/sandboxer/core/manager.py +++ b/src/sandboxer/core/manager.py @@ -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 diff --git a/src/sandboxer/extensions/base.py b/src/sandboxer/extensions/base.py index 7df3ebf..741a170 100644 --- a/src/sandboxer/extensions/base.py +++ b/src/sandboxer/extensions/base.py @@ -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") diff --git a/src/sandboxer/extensions/bwrap.py b/src/sandboxer/extensions/bwrap.py index febca09..e57e7d1 100644 --- a/src/sandboxer/extensions/bwrap.py +++ b/src/sandboxer/extensions/bwrap.py @@ -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] = [] diff --git a/src/sandboxer/extensions/bwrap_runner.py b/src/sandboxer/extensions/bwrap_runner.py index cc7fac4..9ef720c 100644 --- a/src/sandboxer/extensions/bwrap_runner.py +++ b/src/sandboxer/extensions/bwrap_runner.py @@ -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 diff --git a/src/sandboxer/extensions/registry.py b/src/sandboxer/extensions/registry.py index 43e070a..6aecde6 100644 --- a/src/sandboxer/extensions/registry.py +++ b/src/sandboxer/extensions/registry.py @@ -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]: ... diff --git a/src/sandboxer/models.py b/src/sandboxer/models.py index 60b597e..ed237ad 100644 --- a/src/sandboxer/models.py +++ b/src/sandboxer/models.py @@ -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) diff --git a/tests/test_bwrap.py b/tests/test_bwrap.py index 00c483d..e1453e2 100644 --- a/tests/test_bwrap.py +++ b/tests/test_bwrap.py @@ -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" diff --git a/tests/test_manager.py b/tests/test_manager.py index 18b5302..3e4f1b3 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -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, ) diff --git a/workplans/SAND-WP-0014-owner-mediated-execution.md b/workplans/SAND-WP-0014-owner-mediated-execution.md index c5de4d4..cb37b5c 100644 --- a/workplans/SAND-WP-0014-owner-mediated-execution.md +++ b/workplans/SAND-WP-0014-owner-mediated-execution.md @@ -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