From fd9297810c9a5478e3ac431c2fe764a6d62f784f Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 4 Sep 2026 22:25:05 +0200 Subject: [PATCH] Verify bwrap execution across owner API requests Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a06def-6490-7033-8448-2eab2d12ed44 --- Makefile | 5 +- docs/runbooks/profile-bwrap-local.md | 8 ++ scripts/smoke-bwrap-owner-api.py | 107 ++++++++++++++++++ src/sandboxer/extensions/bwrap.py | 1 - tests/test_bwrap.py | 1 + .../SAND-WP-0014-owner-mediated-execution.md | 5 + 6 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 scripts/smoke-bwrap-owner-api.py diff --git a/Makefile b/Makefile index beee4c0..8e326a9 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .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 @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 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 diff --git a/docs/runbooks/profile-bwrap-local.md b/docs/runbooks/profile-bwrap-local.md index b5a2376..b2993b6 100644 --- a/docs/runbooks/profile-bwrap-local.md +++ b/docs/runbooks/profile-bwrap-local.md @@ -76,9 +76,17 @@ sandboxer destroy SANDBOX_ID ``` 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: ```bash 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. diff --git a/scripts/smoke-bwrap-owner-api.py b/scripts/smoke-bwrap-owner-api.py new file mode 100644 index 0000000..eea0f5e --- /dev/null +++ b/scripts/smoke-bwrap-owner-api.py @@ -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()) diff --git a/src/sandboxer/extensions/bwrap.py b/src/sandboxer/extensions/bwrap.py index 47f29fa..febca09 100644 --- a/src/sandboxer/extensions/bwrap.py +++ b/src/sandboxer/extensions/bwrap.py @@ -51,7 +51,6 @@ class BwrapExtension(SandboxExtension): def _bwrap_argv(self, workspace_dir: str, *, info_fd: int | None = None) -> list[str]: argv = [ self._bwrap_bin(), - "--die-with-parent", "--unshare-user", "--unshare-pid", "--unshare-ipc", diff --git a/tests/test_bwrap.py b/tests/test_bwrap.py index bfa0de9..00c483d 100644 --- a/tests/test_bwrap.py +++ b/tests/test_bwrap.py @@ -47,6 +47,7 @@ def test_bwrap_argv_unshares_net_and_binds_workspace(tmp_path) -> None: argv = ext._bwrap_argv(str(tmp_path)) assert "--unshare-net" in argv assert "--unshare-user" in argv + assert "--die-with-parent" not in argv assert str(tmp_path) in argv assert "/run/sandboxer/bwrap_runner.py" in argv assert argv[-4:] == [ diff --git a/workplans/SAND-WP-0014-owner-mediated-execution.md b/workplans/SAND-WP-0014-owner-mediated-execution.md index 19a2b2d..c5de4d4 100644 --- a/workplans/SAND-WP-0014-owner-mediated-execution.md +++ b/workplans/SAND-WP-0014-owner-mediated-execution.md @@ -86,6 +86,11 @@ namespace exposed only `lo` under declared `default: deny`, `egress: []`; no credential routes or values were present; and teardown reported `destroyed` 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 ```task