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
|
Optional (owner-mediated execution): `supports_execution()`, then
|
||||||
`execute(handle, command, credential_route_refs, execution_context,
|
`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
|
`ext.bwrap` is the reference implementation. An executing extension must
|
||||||
validate that its workspace belongs to the exact sandbox handle, sanitize the
|
validate that its workspace belongs to the exact sandbox handle, sanitize the
|
||||||
child environment, use an argument vector, enforce the requested bounds, and
|
child environment, use an argument vector, enforce the requested bounds, and
|
||||||
must not fall back to a host workspace.
|
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
|
### 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
|
extension without an owner execution implementation. It never retries against
|
||||||
the host source checkout.
|
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
|
## Ownership
|
||||||
|
|
||||||
| Concern | Owner |
|
| Concern | Owner |
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,11 @@ sand-boxer commits to:
|
||||||
workspace validation, sanitized environment, bounded duration/output, and no
|
workspace validation, sanitized environment, bounded duration/output, and no
|
||||||
host-checkout or alternate-extension fallback
|
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.
|
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"
|
"run_id": "run-456"
|
||||||
},
|
},
|
||||||
"credential_route_refs": [],
|
"credential_route_refs": [],
|
||||||
|
"stdin_text": null,
|
||||||
"timeout_seconds": 30,
|
"timeout_seconds": 30,
|
||||||
"max_output_bytes": 65536
|
"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
|
The bearer authenticates access to the owner service; exact consumer identity
|
||||||
matching additionally binds the command to the existing sandbox grant.
|
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
|
## Destroy
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,13 +49,14 @@ def main() -> int:
|
||||||
created.raise_for_status()
|
created.raise_for_status()
|
||||||
sandbox_id = created.json()["sandbox_id"]
|
sandbox_id = created.json()["sandbox_id"]
|
||||||
code = (
|
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(); "
|
f"outside=Path({str(outside)!r}).exists(); "
|
||||||
"interfaces=[line.split(':',1)[0].strip() for line in "
|
"interfaces=[line.split(':',1)[0].strip() for line in "
|
||||||
"Path('/proc/net/dev').read_text().splitlines()[2:]]; "
|
"Path('/proc/net/dev').read_text().splitlines()[2:]]; "
|
||||||
"print(json.dumps({'cwd':str(Path.cwd()), 'copied':Path('copied.txt').is_file(), "
|
"print(json.dumps({'cwd':str(Path.cwd()), 'copied':Path('copied.txt').is_file(), "
|
||||||
"'host_source_visible':outside, 'network_interfaces':interfaces, "
|
"'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(
|
executed = client.post(
|
||||||
f"/v1/sandboxes/{sandbox_id}/exec",
|
f"/v1/sandboxes/{sandbox_id}/exec",
|
||||||
|
|
@ -63,6 +64,7 @@ def main() -> int:
|
||||||
json={
|
json={
|
||||||
"command": ["/usr/bin/python3", "-c", code],
|
"command": ["/usr/bin/python3", "-c", code],
|
||||||
"consumer": consumer,
|
"consumer": consumer,
|
||||||
|
"stdin_text": '{"title":"non-secret API smoke"}',
|
||||||
"timeout_seconds": 30,
|
"timeout_seconds": 30,
|
||||||
"max_output_bytes": 65_536,
|
"max_output_bytes": 65_536,
|
||||||
},
|
},
|
||||||
|
|
@ -95,6 +97,8 @@ def main() -> int:
|
||||||
and proof["copied"]
|
and proof["copied"]
|
||||||
and not proof["host_source_visible"]
|
and not proof["host_source_visible"]
|
||||||
and proof["network_interfaces"] == ["lo"]
|
and proof["network_interfaces"] == ["lo"]
|
||||||
|
and proof["stdin_bytes"] == 32
|
||||||
|
and "stdin_text" not in result
|
||||||
and payload["teardown"]["workspace_removed"]
|
and payload["teardown"]["workspace_removed"]
|
||||||
)
|
)
|
||||||
return 0 if passed else 1
|
return 0 if passed else 1
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
@ -157,6 +158,9 @@ def sandbox_exec(
|
||||||
max_output_bytes: Annotated[
|
max_output_bytes: Annotated[
|
||||||
int, typer.Option(help="Per-stream captured output limit")
|
int, typer.Option(help="Per-stream captured output limit")
|
||||||
] = 262_144,
|
] = 262_144,
|
||||||
|
stdin: Annotated[
|
||||||
|
bool, typer.Option("--stdin", help="Read bounded command stdin from this process")
|
||||||
|
] = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run COMMAND inside a bwrap sandbox through its owning process."""
|
"""Run COMMAND inside a bwrap sandbox through its owning process."""
|
||||||
command = list(ctx.args)
|
command = list(ctx.args)
|
||||||
|
|
@ -164,19 +168,20 @@ def sandbox_exec(
|
||||||
command = command[1:]
|
command = command[1:]
|
||||||
if not command:
|
if not command:
|
||||||
raise typer.BadParameter("COMMAND is required after --")
|
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:
|
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)
|
result = SandboxManager().execute(sandbox_id, request)
|
||||||
except (KeyError, PermissionError, RuntimeError, ValueError) as exc:
|
except (KeyError, PermissionError, RuntimeError, ValueError) as exc:
|
||||||
typer.echo(f"Error: {exc}", err=True)
|
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)
|
command_size = sum(len(arg.encode("utf-8")) for arg in request.command)
|
||||||
if command_size > 65_536:
|
if command_size > 65_536:
|
||||||
raise ValueError("command argument vector exceeds 65536 bytes")
|
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 = [
|
invalid_refs = [
|
||||||
ref
|
ref
|
||||||
for ref in request.credential_route_refs
|
for ref in request.credential_route_refs
|
||||||
|
|
@ -304,6 +306,7 @@ class SandboxManager:
|
||||||
execution_context=context,
|
execution_context=context,
|
||||||
timeout_seconds=request.timeout_seconds,
|
timeout_seconds=request.timeout_seconds,
|
||||||
max_output_bytes=request.max_output_bytes,
|
max_output_bytes=request.max_output_bytes,
|
||||||
|
stdin_text=request.stdin_text,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
status.state = SandboxState.READY
|
status.state = SandboxState.READY
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ class SandboxExtension(ABC):
|
||||||
execution_context: dict[str, str],
|
execution_context: dict[str, str],
|
||||||
timeout_seconds: int,
|
timeout_seconds: int,
|
||||||
max_output_bytes: int,
|
max_output_bytes: int,
|
||||||
|
stdin_text: str | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Run a bounded command through an owner-mediated sandbox boundary."""
|
"""Run a bounded command through an owner-mediated sandbox boundary."""
|
||||||
raise NotImplementedError(f"{type(self).__name__} does not support execution")
|
raise NotImplementedError(f"{type(self).__name__} does not support execution")
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,7 @@ class BwrapExtension(SandboxExtension):
|
||||||
execution_context: dict[str, str],
|
execution_context: dict[str, str],
|
||||||
timeout_seconds: int,
|
timeout_seconds: int,
|
||||||
max_output_bytes: int,
|
max_output_bytes: int,
|
||||||
|
stdin_text: str | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Ask the broker already inside bwrap to run an argument-vector command."""
|
"""Ask the broker already inside bwrap to run an argument-vector command."""
|
||||||
pid = int(handle.get("pid", "0"))
|
pid = int(handle.get("pid", "0"))
|
||||||
|
|
@ -197,6 +198,7 @@ class BwrapExtension(SandboxExtension):
|
||||||
"execution_context": execution_context,
|
"execution_context": execution_context,
|
||||||
"timeout_seconds": timeout_seconds,
|
"timeout_seconds": timeout_seconds,
|
||||||
"max_output_bytes": max_output_bytes,
|
"max_output_bytes": max_output_bytes,
|
||||||
|
"stdin_text": stdin_text,
|
||||||
}
|
}
|
||||||
response_limit = max_output_bytes * 2 + 65_536
|
response_limit = max_output_bytes * 2 + 65_536
|
||||||
chunks: list[bytes] = []
|
chunks: list[bytes] = []
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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]:
|
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"])
|
max_output_bytes = int(payload["max_output_bytes"])
|
||||||
credential_refs = payload.get("credential_route_refs", [])
|
credential_refs = payload.get("credential_route_refs", [])
|
||||||
context = payload.get("execution_context", {})
|
context = payload.get("execution_context", {})
|
||||||
|
stdin_text = payload.get("stdin_text")
|
||||||
child_env = {
|
child_env = {
|
||||||
"HOME": str(workspace),
|
"HOME": str(workspace),
|
||||||
"LANG": "C.UTF-8",
|
"LANG": "C.UTF-8",
|
||||||
|
|
@ -39,12 +42,16 @@ def _run(payload: dict, workspace: Path) -> dict[str, object]:
|
||||||
command,
|
command,
|
||||||
cwd=workspace,
|
cwd=workspace,
|
||||||
env=child_env,
|
env=child_env,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
start_new_session=True,
|
start_new_session=True,
|
||||||
)
|
)
|
||||||
try:
|
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
|
exit_code = process.returncode
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
timed_out = True
|
timed_out = True
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ class ExtensionBackend(Protocol):
|
||||||
execution_context: dict[str, str],
|
execution_context: dict[str, str],
|
||||||
timeout_seconds: int,
|
timeout_seconds: int,
|
||||||
max_output_bytes: int,
|
max_output_bytes: int,
|
||||||
|
stdin_text: str | None = None,
|
||||||
) -> dict[str, object]: ...
|
) -> dict[str, object]: ...
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,7 @@ class SandboxExecRequest(BaseModel):
|
||||||
command: list[str] = Field(min_length=1, max_length=256)
|
command: list[str] = Field(min_length=1, max_length=256)
|
||||||
consumer: Consumer
|
consumer: Consumer
|
||||||
credential_route_refs: list[str] = Field(default_factory=list, max_length=32)
|
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)
|
timeout_seconds: int = Field(default=900, ge=1, le=3600)
|
||||||
max_output_bytes: int = Field(default=262_144, ge=1, le=1_048_576)
|
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"},
|
execution_context={"actor": "agt", "run_id": "run-1"},
|
||||||
timeout_seconds=30,
|
timeout_seconds=30,
|
||||||
max_output_bytes=1024,
|
max_output_bytes=1024,
|
||||||
|
stdin_text="task payload",
|
||||||
)
|
)
|
||||||
thread.join(timeout=2)
|
thread.join(timeout=2)
|
||||||
server.close()
|
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["credential_route_refs"] == ["rein-openweights-openrouter-approle"]
|
||||||
assert received["timeout_seconds"] == 30
|
assert received["timeout_seconds"] == 30
|
||||||
assert received["max_output_bytes"] == 1024
|
assert received["max_output_bytes"] == 1024
|
||||||
|
assert received["stdin_text"] == "task payload"
|
||||||
assert result["stdout"] == "ok\n"
|
assert result["stdout"] == "ok\n"
|
||||||
assert result["exit_code"] == 0
|
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"},
|
"execution_context": {"actor": "agt", "run_id": "run-1"},
|
||||||
"timeout_seconds": 30,
|
"timeout_seconds": 30,
|
||||||
"max_output_bytes": 1024,
|
"max_output_bytes": 1024,
|
||||||
|
"stdin_text": "task payload",
|
||||||
}
|
}
|
||||||
with patch(
|
with patch(
|
||||||
"sandboxer.extensions.bwrap_runner.subprocess.Popen", return_value=process
|
"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.args[0] == ["python3", "-V"]
|
||||||
assert popen.call_args.kwargs["cwd"] == tmp_path
|
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"]
|
child_env = popen.call_args.kwargs["env"]
|
||||||
assert child_env["SANDBOXER_ACTOR"] == "agt"
|
assert child_env["SANDBOXER_ACTOR"] == "agt"
|
||||||
assert child_env["SANDBOXER_RUN_ID"] == "run-1"
|
assert child_env["SANDBOXER_RUN_ID"] == "run-1"
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,7 @@ def _exec_request(**consumer_overrides) -> SandboxExecRequest:
|
||||||
command=["python3", "-V"],
|
command=["python3", "-V"],
|
||||||
consumer=Consumer.model_validate(consumer),
|
consumer=Consumer.model_validate(consumer),
|
||||||
credential_route_refs=["rein-openweights-openrouter-approle"],
|
credential_route_refs=["rein-openweights-openrouter-approle"],
|
||||||
|
stdin_text='{"title":"bounded task"}',
|
||||||
timeout_seconds=30,
|
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
|
could disappear before a later API `exec`. Owner sandboxes now persist across
|
||||||
requests and remain bounded by explicit destroy, TTL expiry, and stale reaping.
|
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
|
## Prove one governed rein and coordinate consumers
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue