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:
parent
d79e3fe358
commit
fd9297810c
6 changed files with 125 additions and 2 deletions
5
Makefile
5
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
107
scripts/smoke-bwrap-owner-api.py
Normal file
107
scripts/smoke-bwrap-owner-api.py
Normal 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())
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:] == [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue