Verify bwrap execution across owner API requests

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:25:05 +02:00
parent d79e3fe358
commit fd9297810c
6 changed files with 125 additions and 2 deletions

View file

@ -1,6 +1,6 @@
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.PHONY: help setup install test lint format build check cli-version smoke-remote smoke-bwrap-exec .PHONY: help setup install test lint format build check cli-version smoke-remote smoke-bwrap-exec smoke-bwrap-owner-api
help: ## List available make targets help: ## List available make targets
@awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_.-]+:.*## / {printf " %-16s %s\n", $$1, $$2}' $(MAKEFILE_LIST) @awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_.-]+:.*## / {printf " %-16s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@ -33,3 +33,6 @@ smoke-remote: ## T10 remote create/destroy smoke (needs SANDBOXER_HOST)
smoke-bwrap-exec: ## Owner-mediated local bwrap create/exec/destroy proof smoke-bwrap-exec: ## Owner-mediated local bwrap create/exec/destroy proof
uv run python scripts/smoke-bwrap-exec.py uv run python scripts/smoke-bwrap-exec.py
smoke-bwrap-owner-api: ## Authenticated HTTP bwrap create/exec/destroy proof
uv run python scripts/smoke-bwrap-owner-api.py

View file

@ -76,9 +76,17 @@ sandboxer destroy SANDBOX_ID
``` ```
Destroy kills the namespace process group and removes the copied workspace. Destroy kills the namespace process group and removes the copied workspace.
The namespace is not tied to the process that handled `create`, because the
resident API handles create and exec as separate requests. TTL expiry and the
stale-resource reaper provide crash recovery for an owner service that exits
before explicit destroy.
For a non-secret create/execute/source-absence/destroy proof, run: For a non-secret create/execute/source-absence/destroy proof, run:
```bash ```bash
make smoke-bwrap-exec make smoke-bwrap-exec
make smoke-bwrap-owner-api
``` ```
The second target exercises the bearer-protected HTTP route with the real bwrap
backend, not a mocked manager.

View file

@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Live authenticated HTTP proof for the ext.bwrap owner execution route."""
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from fastapi.testclient import TestClient
from sandboxer.api import app as api_module
from sandboxer.core.manager import SandboxManager
from sandboxer.lifecycle.store import SandboxStore
def main() -> int:
with tempfile.TemporaryDirectory(prefix="sandboxer-owner-api-proof-") as temp_value:
temp_dir = Path(temp_value)
source = temp_dir / "source"
source.mkdir()
(source / "copied.txt").write_text("copied\n")
outside = temp_dir / "host-source-sentinel"
outside.write_text("must-not-be-visible\n")
api_module._manager = SandboxManager(
store=SandboxStore(path=temp_dir / "sandboxes.json")
)
os.environ["SANDBOXER_EXEC_TOKEN"] = "non-secret-smoke-capability"
os.environ["SANDBOXER_NO_STATE_HUB"] = "1"
client = TestClient(api_module.app)
consumer = {
"actor": "agt",
"project": "sand-boxer-api-smoke",
"session_id": "api-proof",
"run_id": "api-proof-1",
}
sandbox_id = None
try:
created = client.post(
"/v1/sandboxes",
json={
"profile": "profile.bwrap-local",
"inputs": {"repo": str(source)},
"consumer": consumer,
"ttl": "5m",
},
)
created.raise_for_status()
sandbox_id = created.json()["sandbox_id"]
code = (
"import json, os; from pathlib import Path; "
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')}))"
)
executed = client.post(
f"/v1/sandboxes/{sandbox_id}/exec",
headers={"Authorization": "Bearer non-secret-smoke-capability"},
json={
"command": ["/usr/bin/python3", "-c", code],
"consumer": consumer,
"timeout_seconds": 30,
"max_output_bytes": 65_536,
},
)
if not executed.is_success:
raise RuntimeError(
f"owner exec HTTP {executed.status_code}: {executed.text}"
)
result = executed.json()
proof = json.loads(result["stdout"])
destroyed = client.delete(f"/v1/sandboxes/{sandbox_id}")
destroyed.raise_for_status()
payload = {
"sandbox_id": sandbox_id,
"http_exec_status": executed.status_code,
"exit_code": result["exit_code"],
"consumer": result["consumer"],
"network_default": result["network_default"],
"network_egress": result["network_egress"],
"proof": proof,
"teardown": {
"state": destroyed.json()["state"],
"workspace_removed": not Path(result["workspace_dir"]).exists(),
},
}
print(json.dumps(payload, indent=2))
passed = (
result["exit_code"] == 0
and result["consumer"] == consumer
and proof["copied"]
and not proof["host_source_visible"]
and proof["network_interfaces"] == ["lo"]
and payload["teardown"]["workspace_removed"]
)
return 0 if passed else 1
finally:
if sandbox_id:
api_module._manager.destroy(sandbox_id)
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -51,7 +51,6 @@ class BwrapExtension(SandboxExtension):
def _bwrap_argv(self, workspace_dir: str, *, info_fd: int | None = None) -> list[str]: def _bwrap_argv(self, workspace_dir: str, *, info_fd: int | None = None) -> list[str]:
argv = [ argv = [
self._bwrap_bin(), self._bwrap_bin(),
"--die-with-parent",
"--unshare-user", "--unshare-user",
"--unshare-pid", "--unshare-pid",
"--unshare-ipc", "--unshare-ipc",

View file

@ -47,6 +47,7 @@ def test_bwrap_argv_unshares_net_and_binds_workspace(tmp_path) -> None:
argv = ext._bwrap_argv(str(tmp_path)) argv = ext._bwrap_argv(str(tmp_path))
assert "--unshare-net" in argv assert "--unshare-net" in argv
assert "--unshare-user" in argv assert "--unshare-user" in argv
assert "--die-with-parent" not in argv
assert str(tmp_path) in argv assert str(tmp_path) in argv
assert "/run/sandboxer/bwrap_runner.py" in argv assert "/run/sandboxer/bwrap_runner.py" in argv
assert argv[-4:] == [ assert argv[-4:] == [

View file

@ -86,6 +86,11 @@ namespace exposed only `lo` under declared `default: deny`, `egress: []`; no
credential routes or values were present; and teardown reported `destroyed` credential routes or values were present; and teardown reported `destroyed`
with the workspace removed. with the workspace removed.
The authenticated HTTP smoke subsequently exposed and fixed a lifecycle defect:
`--die-with-parent` tied bwrap to the process handling `create`, so the namespace
could disappear before a later API `exec`. Owner sandboxes now persist across
requests and remain bounded by explicit destroy, TTL expiry, and stale reaping.
## Prove one governed rein and coordinate consumers ## Prove one governed rein and coordinate consumers
```task ```task