Add owner-mediated bwrap execution boundary

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:12:19 +02:00
parent 877676d1f1
commit d79e3fe358
23 changed files with 1321 additions and 86 deletions

View file

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

View file

@ -21,6 +21,7 @@
| workplan | SAND-WP-0011 | finished | — | workplans/SAND-WP-0011-reachability-and-consumer-profiles.md |
| workplan | SAND-WP-0012 | finished | — | workplans/SAND-WP-0012-packer-orchestration.md |
| workplan | SAND-WP-0013 | finished | — | workplans/SAND-WP-0013-bwrap-extension.md |
| workplan | SAND-WP-0014 | active | — | workplans/SAND-WP-0014-owner-mediated-execution.md |
| task | SAND-WP-0001-T01 | done | — | workplans/SAND-WP-0001-statehub-bootstrap.md |
| task | SAND-WP-0001-T02 | done | — | workplans/SAND-WP-0001-statehub-bootstrap.md |
| task | SAND-WP-0001-T03 | done | — | workplans/SAND-WP-0001-statehub-bootstrap.md |
@ -111,3 +112,8 @@
| task | SAND-WP-0013-T04 | done | — | workplans/SAND-WP-0013-bwrap-extension.md |
| task | SAND-WP-0013-T05 | done | — | workplans/SAND-WP-0013-bwrap-extension.md |
| task | SAND-WP-0013-T06 | done | — | workplans/SAND-WP-0013-bwrap-extension.md |
| task | SAND-WP-0014-T01 | done | — | workplans/SAND-WP-0014-owner-mediated-execution.md |
| task | SAND-WP-0014-T02 | done | — | workplans/SAND-WP-0014-owner-mediated-execution.md |
| task | SAND-WP-0014-T03 | done | — | workplans/SAND-WP-0014-owner-mediated-execution.md |
| task | SAND-WP-0014-T04 | done | — | workplans/SAND-WP-0014-owner-mediated-execution.md |
| task | SAND-WP-0014-T05 | wait | — | workplans/SAND-WP-0014-owner-mediated-execution.md |

View file

@ -17,6 +17,14 @@ Optional (SaaS): `estimate_cost(profile, duration) → MeterQuote`
Optional (checkpoints): `supports_snapshots()`, `snapshot(handle)`,
`restore_from_snapshot(profile, snapshot_meta, inputs, host)`
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.
`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.
### Base class
```python
@ -37,6 +45,7 @@ Reference implementations:
| `ext.saas-stub` | `saas_stub.py` | Metered stub + metadata snapshots |
| `ext.e2b` | `e2b.py` | E2B cloud adapter |
| `ext.modal` | `modal.py` | Modal cloud adapter |
| `ext.bwrap` | `bwrap.py` | Local namespaces + owner-mediated execution |
## Registration
@ -111,4 +120,4 @@ Implement `estimate_cost` and `meter_actual` on `SandboxExtension`. Register wit
| Packer build orchestration from `create` | Future WP |
| Daytona OSS cloud adapter | Future WP |
| fin-hub billing export | Future |
| Cross-host snapshot transfer | Future |
| Cross-host snapshot transfer | Future |

View file

@ -10,7 +10,9 @@ sandboxer create \
--profile profile.agent-dev \
--input repo=/path/to/workspace \
--actor agt \
--project glas-harness
--project glas-harness \
--session-id session-123 \
--run-id run-456
```
## Response fields (ready state)
@ -22,28 +24,31 @@ sandboxer create \
| `reachability.remote_dir` | sand-boxer | Workspace root on remote host |
| `state` | sand-boxer | Lifecycle state (`ready`, etc.) |
## Two reachability modes
## Two execution modes
Not every sandbox has an SSH hop. `ext.compose-ssh` / `ext.vm-packer`
always run remote and populate `reachability.ssh` + `reachability.
remote_dir`; glas-harness execs tools over that SSH channel. `ext.bwrap`
(SAND-WP-0013) runs same-host and never populates `reachability.ssh`
instead it populates `reachability.pid` (the placeholder process holding
the bwrap namespaces) and `reachability.workspace_dir`. glas-harness
execs tools by entering that pid's namespaces directly
(`nsenter --target <pid> --mount --pid --net --uts --ipc -- <cmd>`, no
tunnel needed) rather than opening an SSH channel. `reachability/enrich.
build_reachability_report()` returns both an `ssh_one_liner` and a
`local_exec_hint`; exactly one is non-null depending on which mode the
resolved extension uses. Consumers should branch on which field is
populated, not on profile id, since routing can fall back between
extensions.
the bwrap namespaces) and `reachability.workspace_dir` as evidence. These
fields are not a consumer attach contract. `build_reachability_report()`
returns `execution.mode: owner-mediated`; glas-harness sends an exec request
to the resident sand-boxer owner service. Direct `nsenter` is unsupported.
The request repeats the exact actor/project/session/run identity used at
create, carries an argument vector (never a shell command string), and may
carry only value-free credential catalog route references. sand-boxer refuses
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.
## Ownership
| Concern | Owner |
|---------|-------|
| Sandbox provision / teardown | sand-boxer |
| In-namespace command broker, workspace cwd, timeout/output bounds | sand-boxer |
| Tool call parsing and policies | glas-harness |
| SSH / tunnel reachability setup | glas-harness + ops-bridge |
| Agent memory and session state | glas-harness |
@ -62,4 +67,5 @@ one-liner), then destroys.
- Tool schemas and approval flows
- Channel bridges (Slack, email, etc.)
- Subagent orchestration
- Subagent orchestration
- Provider credential acquisition or injection (the selected rein owns it)

View file

@ -92,7 +92,7 @@ Extends the `build-agent` self-register pattern: generic sandbox identities carr
| `recreate` | Destroy and reprovision from stored seed | **Yes** |
| `destroy` | Idempotent teardown | **Yes** |
| `snapshot` / `restore` | Checkpoint workspace | **Yes** (compose-ssh, saas-stub) |
| `exec` | Run command in sandbox | Harness-owned via SSH (glas-harness) |
| `exec` | Owner-mediated command in a local bwrap sandbox | **Yes** (`ext.bwrap` only) |
HTTP surface (optional v0; CLI calls core library directly):
@ -107,6 +107,7 @@ HTTP surface (optional v0; CLI calls core library directly):
- `PATCH /v1/sandboxes/{id}/ttl` — extend TTL
- `POST /v1/sandboxes/expire` — TTL reap (query `apply=true`)
- `GET /v1/sandboxes/{id}/reachability` — enriched descriptor + SSH one-liner
- `POST /v1/sandboxes/{id}/exec` — authenticated owner-mediated bwrap command
---
@ -133,6 +134,12 @@ Tunnel metadata is enriched from profile `reachability` and environment:
sand-boxer **does not** bring tunnels up. Consumers use ops-bridge (MCP or
`bridge` CLI) to attach SSH routes; the descriptor is a pointer only.
For `ext.bwrap`, reachability is not an invitation to enter the recorded PID.
The report contains `execution.mode: owner-mediated`; callers use `sandboxer
exec` when running as the sandbox owner or the authenticated HTTP exec endpoint
when calling the resident owner service. Direct consumer-side `nsenter` is not
supported.
`secret_refs` from `profile.setup` are resolved at the provision boundary and
passed to the extension handle — they never appear on `SandboxStatus` or State
Hub events.
@ -159,6 +166,8 @@ consumer:
sand-boxer records attribution on every lifecycle event. It does not interpret
agent intent or authorize the caller — flex-auth owns authorization when enforced.
An exec request must nevertheless repeat this block exactly. Any actor, project,
session, or run mismatch is refused before extension dispatch.
---
@ -170,6 +179,8 @@ Each extension implements:
provision(profile, inputs, placement) → SandboxHandle
wait_ready(handle) → Reachability
teardown(handle) → CleanupReport
supports_execution?() → bool
execute?(handle, argv, credential_route_refs, execution_context, bounds) → CommandResult
estimate_cost?(profile, duration) → MeterQuote # optional; SaaS only
```
@ -210,6 +221,9 @@ sand-boxer commits to:
from Railiance01 production
4. **Observable lifecycle** — every transition attributed to `adm` / `agt` / `atm`
5. **Honest limits** — allowed tool paths can be abused by compromised agents
6. **Fail-closed local execution** — exact identity/state/TTL binding, owner-managed
workspace validation, sanitized environment, bounded duration/output, and no
host-checkout or alternate-extension fallback
sand-boxer does **not** provide intent-aware egress filtering in v1.
@ -226,4 +240,4 @@ sand-boxer does **not** provide intent-aware egress filtering in v1.
| SSH certificates | ops-warden |
| Workstream / task state | state-hub |
See `docs/integrations/` for per-sibling contracts.
See `docs/integrations/` for per-sibling contracts.

View file

@ -0,0 +1,84 @@
# Runbook: profile.bwrap-local owner execution
`profile.bwrap-local` copies the requested repository into an owner-managed
workspace and starts a bubblewrap namespace with network default-deny. Commands
must cross the sand-boxer owner boundary; direct namespace entry is unsupported.
## Create with governed identity
```bash
sandboxer create \
--profile profile.bwrap-local \
--input repo=/path/to/repository \
--actor agt \
--project glas-harness \
--session-id session-123 \
--run-id run-456
```
Keep the returned sandbox id. The source path is copied; it is not mounted into
the namespace.
## Execute as the local owner
```bash
sandboxer exec SANDBOX_ID \
--actor agt \
--project glas-harness \
--session-id session-123 \
--run-id run-456 \
-- python3 -c 'from pathlib import Path; print(Path.cwd())'
```
The result records the bound identity, profile and extension, command name,
exit/timeout outcome, workspace, and declared network posture. Captured stdout
and stderr default to 256 KiB per stream and the timeout defaults to 15 minutes.
Credential values are never command options or environment assignments. A rein
that owns credential acquisition may receive only a catalog identifier, for
example `--credential-route-ref rein-openweights-openrouter-approle`; inside the
sandbox that appears as the value-free JSON list
`SANDBOXER_CREDENTIAL_ROUTE_REFS`. The selected rein resolves and cleans up the
credential through its approved owner-fronted route.
## Resident owner API
The high-risk HTTP route is disabled unless the service process receives
`SANDBOXER_EXEC_TOKEN` through its approved service credential delivery path.
Before provisioning that credential, locate its custody route with `warden route
find "sand-boxer owner execution service credential" --json`; do not paste it
into a shell history, workplan, State Hub, or sandbox mount.
Call `POST /v1/sandboxes/SANDBOX_ID/exec` with a bearer token and this body:
```json
{
"command": ["python3", "-V"],
"consumer": {
"actor": "agt",
"project": "glas-harness",
"session_id": "session-123",
"run_id": "run-456"
},
"credential_route_refs": [],
"timeout_seconds": 30,
"max_output_bytes": 65536
}
```
The bearer authenticates access to the owner service; exact consumer identity
matching additionally binds the command to the existing sandbox grant.
## Destroy
```bash
sandboxer destroy SANDBOX_ID
```
Destroy kills the namespace process group and removes the copied workspace.
For a non-secret create/execute/source-absence/destroy proof, run:
```bash
make smoke-bwrap-exec
```

View file

@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Non-secret local proof for the owner-mediated bwrap execution boundary."""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
from sandboxer.core.manager import SandboxManager
from sandboxer.lifecycle.store import SandboxStore
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest, SandboxExecRequest
def main() -> int:
with tempfile.TemporaryDirectory(prefix="sandboxer-bwrap-proof-") as temp_value:
temp_dir = Path(temp_value)
source = temp_dir / "source"
source.mkdir()
(source / "copied.txt").write_text("copied\n")
outside_sentinel = temp_dir / "host-source-sentinel"
outside_sentinel.write_text("must-not-be-visible\n")
manager = SandboxManager(store=SandboxStore(path=temp_dir / "sandboxes.json"))
consumer = Consumer(
actor=ActorType.AGT,
project="sand-boxer-smoke",
session_id="non-secret-proof",
run_id="non-secret-proof-1",
)
status = manager.create(
SandboxCreateRequest(
profile="profile.bwrap-local",
inputs={"repo": str(source)},
consumer=consumer,
ttl="5m",
)
)
try:
proof_code = (
"import json, os; from pathlib import Path; "
"p=Path('proof.txt'); p.write_text('inside\\n'); "
f"outside=Path({str(outside_sentinel)!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(), 'artifact': p.is_file(), "
"'host_source_visible': outside, "
"'network_interfaces': interfaces, "
"'actor': os.environ.get('SANDBOXER_ACTOR'), "
"'run_id': os.environ.get('SANDBOXER_RUN_ID'), "
"'credential_refs': os.environ.get('SANDBOXER_CREDENTIAL_ROUTE_REFS')}))"
)
result = manager.execute(
status.sandbox_id,
SandboxExecRequest(
command=["/usr/bin/python3", "-c", proof_code],
consumer=consumer,
timeout_seconds=30,
max_output_bytes=65_536,
),
)
payload = result.model_dump(mode="json")
try:
proof = json.loads(result.stdout)
except json.JSONDecodeError:
proof = None
payload["proof"] = proof
destroyed = manager.destroy(status.sandbox_id)
payload["teardown"] = {
"state": destroyed.state.value,
"workspace_removed": not Path(result.workspace_dir).exists(),
}
print(json.dumps(payload, indent=2))
passed = (
result.exit_code == 0
and proof is not None
and not proof["host_source_visible"]
and proof["network_interfaces"] == ["lo"]
and payload["teardown"]["workspace_removed"]
)
return 0 if passed else 1
finally:
manager.destroy(status.sandbox_id)
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -2,13 +2,18 @@
from __future__ import annotations
from fastapi import FastAPI, HTTPException
import hmac
import os
from fastapi import FastAPI, Header, HTTPException
from sandboxer.core.manager import SandboxManager
from sandboxer.models import (
ExpireActionResult,
ExtendTtlRequest,
SandboxCreateRequest,
SandboxExecRequest,
SandboxExecResult,
SandboxStatus,
SnapshotRecord,
SnapshotRestoreRequest,
@ -18,6 +23,16 @@ app = FastAPI(title="sand-boxer", version="0.0.0")
_manager = SandboxManager()
def _authorize_exec(authorization: str | None) -> None:
"""Require an explicit owner-service capability on the high-risk exec route."""
expected = os.environ.get("SANDBOXER_EXEC_TOKEN")
if not expected:
raise HTTPException(status_code=503, detail="owner execution API is not configured")
scheme, _, supplied = (authorization or "").partition(" ")
if scheme.lower() != "bearer" or not hmac.compare_digest(supplied, expected):
raise HTTPException(status_code=401, detail="invalid owner execution credential")
@app.post("/v1/sandboxes", response_model=SandboxStatus)
def create_sandbox(request: SandboxCreateRequest, host: str | None = None) -> SandboxStatus:
try:
@ -42,6 +57,25 @@ def get_sandbox_reachability(sandbox_id: str) -> dict:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.post("/v1/sandboxes/{sandbox_id}/exec", response_model=SandboxExecResult)
def execute_in_sandbox(
sandbox_id: str,
request: SandboxExecRequest,
authorization: str | None = Header(default=None),
) -> SandboxExecResult:
_authorize_exec(authorization)
try:
return _manager.execute(sandbox_id, request)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/v1/sandboxes", response_model=list[SandboxStatus])
def list_sandboxes() -> list[SandboxStatus]:
return _manager.list()
@ -117,4 +151,4 @@ def extend_sandbox_ttl(sandbox_id: str, request: ExtendTtlRequest) -> SandboxSta
@app.post("/v1/sandboxes/expire", response_model=list[ExpireActionResult])
def expire_sandboxes(apply: bool = False) -> list[ExpireActionResult]:
return _manager.expire(apply=apply)
return _manager.expire(apply=apply)

View file

@ -10,7 +10,7 @@ import typer
from sandboxer import __version__
from sandboxer.core.manager import SandboxManager
from sandboxer.defaults import resolve_create_defaults
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest, SandboxExecRequest
from sandboxer.payments.credits import CreditsStore
from sandboxer.placement import resolve_host
from sandboxer.profiles.loader import load_profile
@ -93,6 +93,10 @@ def sandbox_create(
project: Annotated[str, typer.Option(help="Calling project id")] = "sand-boxer",
host: Annotated[str | None, typer.Option(help="Override placement host")] = None,
ttl: Annotated[str | None, typer.Option(help="TTL override (e.g. 4h)")] = None,
session_id: Annotated[
str | None, typer.Option(help="Governed consumer session id")
] = None,
run_id: Annotated[str | None, typer.Option(help="Governed consumer run id")] = None,
) -> None:
"""Provision a sandbox. No args → canary self-deploy of sand-boxer.
@ -104,7 +108,12 @@ def sandbox_create(
request = SandboxCreateRequest(
profile=resolved_profile,
inputs=resolved_inputs,
consumer=Consumer(actor=ActorType(actor), project=project),
consumer=Consumer(
actor=ActorType(actor),
project=project,
session_id=session_id,
run_id=run_id,
),
ttl=ttl,
)
manager = SandboxManager()
@ -130,6 +139,51 @@ def reachability_show(sandbox_id: str) -> None:
_print_json(report)
@app.command("exec", context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
def sandbox_exec(
ctx: typer.Context,
sandbox_id: Annotated[str, typer.Argument(help="Ready sandbox id")],
actor: Annotated[str, typer.Option(help="Recorded consumer actor")],
project: Annotated[str, typer.Option(help="Recorded consumer project")],
session_id: Annotated[
str | None, typer.Option(help="Recorded consumer session id")
] = None,
run_id: Annotated[str | None, typer.Option(help="Recorded consumer run id")] = None,
credential_route_ref: Annotated[
list[str] | None,
typer.Option(help="Value-free credential catalog route (repeatable)"),
] = None,
timeout: Annotated[int, typer.Option(help="Command timeout in seconds")] = 900,
max_output_bytes: Annotated[
int, typer.Option(help="Per-stream captured output limit")
] = 262_144,
) -> None:
"""Run COMMAND inside a bwrap sandbox through its owning process."""
command = list(ctx.args)
if command and command[0] == "--":
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:
result = SandboxManager().execute(sandbox_id, request)
except (KeyError, PermissionError, RuntimeError, ValueError) as exc:
typer.echo(f"Error: {exc}", err=True)
raise typer.Exit(code=1) from exc
_print_json(result.model_dump(mode="json"))
@app.command("get")
def sandbox_get(sandbox_id: str) -> None:
"""Get sandbox status by id."""
@ -338,4 +392,4 @@ def credits_add(
if __name__ == "__main__":
app()
app()

View file

@ -2,6 +2,10 @@
from __future__ import annotations
import re
from pathlib import Path
from threading import Lock
from sandboxer.extensions.registry import load_extension, resolve_backend
from sandboxer.lifecycle.expire import (
ExpireCandidate,
@ -17,6 +21,8 @@ from sandboxer.models import (
MeterRecord,
Reachability,
SandboxCreateRequest,
SandboxExecRequest,
SandboxExecResult,
SandboxState,
SandboxStatus,
SnapshotRecord,
@ -39,6 +45,8 @@ from sandboxer.telemetry.introspection import (
class SandboxManager:
_CREDENTIAL_ROUTE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$")
def __init__(
self,
store: SandboxStore | None = None,
@ -48,6 +56,7 @@ class SandboxManager:
self.store = store or SandboxStore()
self.credits = credits or CreditsStore()
self.snapshots = snapshots or SnapshotStore()
self._exec_locks: dict[str, Lock] = {}
@staticmethod
def _handle_from_status(status: SandboxStatus) -> dict[str, str]:
@ -207,6 +216,138 @@ class SandboxManager:
return build_reachability_report(status)
@staticmethod
def _validate_exec_consumer(status: SandboxStatus, request: SandboxExecRequest) -> None:
if request.consumer != status.consumer:
raise PermissionError(
"execution consumer does not exactly match sandbox "
"actor/project/session/run identity"
)
@classmethod
def _validate_exec_request(cls, request: SandboxExecRequest) -> None:
if not request.command[0] or any("\x00" in arg for arg in request.command):
raise ValueError("command arguments must be non-empty and contain no NUL bytes")
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")
invalid_refs = [
ref
for ref in request.credential_route_refs
if not cls._CREDENTIAL_ROUTE_RE.fullmatch(ref)
]
if invalid_refs:
raise ValueError("credential route references must be value-free catalog identifiers")
if len(set(request.credential_route_refs)) != len(request.credential_route_refs):
raise ValueError("credential route references must be unique")
def execute(self, sandbox_id: str, request: SandboxExecRequest) -> SandboxExecResult:
"""Run a command through the owning extension without a host fallback."""
lock = self._exec_locks.setdefault(sandbox_id, Lock())
if not lock.acquire(blocking=False):
raise RuntimeError("Sandbox already has an active owner-mediated command")
try:
return self._execute_locked(sandbox_id, request)
finally:
lock.release()
def _execute_locked(
self, sandbox_id: str, request: SandboxExecRequest
) -> SandboxExecResult:
status = self.store.get(sandbox_id)
if not status:
raise KeyError(f"Sandbox not found: {sandbox_id}")
self._validate_exec_request(request)
self._validate_exec_consumer(status, request)
if status.state != SandboxState.READY:
raise RuntimeError(
f"Sandbox must be ready for execution (state={status.state.value})"
)
now = utcnow()
if status.expires_at and now >= status.expires_at:
raise RuntimeError("Sandbox TTL has expired")
profile = load_profile(status.profile_id)
extension = load_extension(status.extension_id)
backend = resolve_backend(extension)
if not backend.supports_execution():
raise RuntimeError(
f"Extension {status.extension_id} has no owner-mediated execution boundary"
)
command_name = Path(request.command[0]).name
status.state = SandboxState.ACTIVE
status.updated_at = now
self.store.save(status)
emit_lifecycle_event(
status,
summary=f"Owner-mediated command started ({command_name})",
event_type=event_type_for_state(status.state),
)
context = {
"sandbox_id": status.sandbox_id,
"profile_id": status.profile_id,
"actor": status.consumer.actor.value,
"project": status.consumer.project,
}
if status.consumer.session_id:
context["session_id"] = status.consumer.session_id
if status.consumer.run_id:
context["run_id"] = status.consumer.run_id
try:
execution = backend.execute(
self._handle_from_status(status),
request.command,
credential_route_refs=request.credential_route_refs,
execution_context=context,
timeout_seconds=request.timeout_seconds,
max_output_bytes=request.max_output_bytes,
)
except Exception as exc:
status.state = SandboxState.READY
status.updated_at = utcnow()
self.store.save(status)
emit_lifecycle_event(
status,
summary=f"Owner-mediated command boundary failed ({command_name}): {exc}",
event_type="note",
)
raise
completed_at = utcnow()
status.state = SandboxState.READY
status.updated_at = completed_at
self.store.save(status)
emit_lifecycle_event(
status,
summary=(
f"Owner-mediated command completed ({command_name}, "
f"exit={execution['exit_code']}, timed_out={execution['timed_out']})"
),
event_type=event_type_for_state(status.state),
)
return SandboxExecResult(
sandbox_id=status.sandbox_id,
profile_id=status.profile_id,
extension_id=status.extension_id,
consumer=status.consumer,
command_name=command_name,
exit_code=int(execution["exit_code"]),
timed_out=bool(execution["timed_out"]),
stdout=str(execution["stdout"]),
stderr=str(execution["stderr"]),
output_truncated=bool(execution["output_truncated"]),
duration_seconds=max(0.0, (completed_at - now).total_seconds()),
workspace_dir=str(execution["workspace_dir"]),
network_default=profile.network.default,
network_egress=profile.network.egress,
credential_route_refs=request.credential_route_refs,
started_at=now,
completed_at=completed_at,
)
def list(self) -> list[SandboxStatus]:
return sorted(self.store.list_all(), key=lambda s: s.created_at, reverse=True)
@ -510,4 +651,4 @@ class SandboxManager:
summary=f"Snapshot restore failed: {exc}",
event_type=event_type_for_state(status.state),
)
raise
raise

View file

@ -47,6 +47,23 @@ class SandboxExtension(ABC):
"""Optional post-destroy actual cost in USD."""
return None
def supports_execution(self) -> bool:
"""Whether the owner can run a command inside an established sandbox."""
return False
def execute(
self,
handle: dict[str, str],
command: list[str],
*,
credential_route_refs: list[str],
execution_context: dict[str, str],
timeout_seconds: int,
max_output_bytes: int,
) -> dict[str, object]:
"""Run a bounded command through an owner-mediated sandbox boundary."""
raise NotImplementedError(f"{type(self).__name__} does not support execution")
def supports_snapshots(self) -> bool:
"""Whether this extension implements checkpoint snapshot/restore."""
return False
@ -63,4 +80,4 @@ class SandboxExtension(ABC):
host: str,
) -> dict[str, str]:
"""Provision a new sandbox from a prior checkpoint."""
raise NotImplementedError(f"{type(self).__name__} does not support restore")
raise NotImplementedError(f"{type(self).__name__} does not support restore")

View file

@ -2,10 +2,15 @@
from __future__ import annotations
import json
import os
import select
import shutil
import signal
import socket
import subprocess
import time
from contextlib import suppress
from pathlib import Path
from typing import Any
@ -19,8 +24,8 @@ class BwrapExtension(SandboxExtension):
Unlike ext.compose-ssh / ext.vm-packer, this extension never leaves the
local host: no SSH hop, no container runtime, no remote placement. A new
user/mount/pid/ipc/uts/net namespace is created per sandbox, kept alive
by a long-running placeholder process (`sleep infinity`) whose pid is
the handle's exec target. `--unshare-net` with no veth/interface makes
by a minimal command broker whose namespace pid is retained for lifecycle
evidence and teardown. `--unshare-net` with no veth/interface makes
`network.default: deny` real, rather than declarative-only like the
other self-hosted extensions.
"""
@ -30,6 +35,9 @@ class BwrapExtension(SandboxExtension):
cfg = self.config
self.base_dir: str = cfg.get("base_dir", "/tmp/sandboxer-bwrap")
self.bwrap_bin: str = cfg.get("bwrap_bin", "bwrap")
self.control_socket_name: str = cfg.get(
"control_socket_name", ".sandboxer-owner.sock"
)
self.ro_binds: list[str] = cfg.get(
"ro_binds", ["/usr", "/bin", "/lib", "/lib64", "/etc/resolv.conf"]
)
@ -40,7 +48,7 @@ class BwrapExtension(SandboxExtension):
def _existing_ro_binds(self) -> list[str]:
return [path for path in self.ro_binds if Path(path).exists()]
def _bwrap_argv(self, workspace_dir: str) -> list[str]:
def _bwrap_argv(self, workspace_dir: str, *, info_fd: int | None = None) -> list[str]:
argv = [
self._bwrap_bin(),
"--die-with-parent",
@ -56,14 +64,42 @@ class BwrapExtension(SandboxExtension):
"/proc",
"--dev",
"/dev",
"--clearenv",
]
if info_fd is not None:
argv += ["--info-fd", str(info_fd)]
for path in self._existing_ro_binds():
argv += ["--ro-bind", path, path]
runner = Path(__file__).with_name("bwrap_runner.py")
argv += ["--dir", "/run", "--dir", "/run/sandboxer"]
argv += ["--ro-bind", str(runner), "/run/sandboxer/bwrap_runner.py"]
argv += ["--bind", workspace_dir, workspace_dir]
argv += ["--chdir", workspace_dir]
argv += ["sleep", "infinity"]
argv += [
"/usr/bin/python3",
"/run/sandboxer/bwrap_runner.py",
workspace_dir,
f"{workspace_dir}/{self.control_socket_name}",
]
return argv
@staticmethod
def _read_child_pid(proc: subprocess.Popen, info_fd: int) -> int:
ready, _, _ = select.select([info_fd], [], [], 10)
if not ready:
proc.kill()
raise RuntimeError("timed out waiting for bwrap namespace child pid")
raw = os.read(info_fd, 16_384)
try:
child_pid = int(json.loads(raw)["child-pid"])
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
proc.kill()
raise RuntimeError("bwrap did not report a valid namespace child pid") from exc
if child_pid <= 0:
proc.kill()
raise RuntimeError("bwrap reported an invalid namespace child pid")
return child_pid
def provision(
self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]:
@ -77,19 +113,30 @@ class BwrapExtension(SandboxExtension):
if not repo_path.exists():
raise FileNotFoundError(f"Repo path does not exist: {repo_path}")
shutil.copytree(repo_path, workspace_dir, dirs_exist_ok=True)
Path(workspace_dir).chmod(0o700)
argv = self._bwrap_argv(workspace_dir)
proc = subprocess.Popen(
argv,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
info_read_fd, info_write_fd = os.pipe()
try:
argv = self._bwrap_argv(workspace_dir, info_fd=info_write_fd)
proc = subprocess.Popen(
argv,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
pass_fds=(info_write_fd,),
)
finally:
os.close(info_write_fd)
try:
child_pid = self._read_child_pid(proc, info_read_fd)
finally:
os.close(info_read_fd)
return {
"sandbox_id": sandbox_id,
"host": host,
"pid": str(proc.pid),
"pid": str(child_pid),
"supervisor_pid": str(proc.pid),
"workspace_dir": workspace_dir,
}
@ -100,11 +147,79 @@ class BwrapExtension(SandboxExtension):
workspace_dir = handle["workspace_dir"]
if not Path(workspace_dir).is_dir():
raise RuntimeError(f"workspace missing: {workspace_dir}")
control_socket = Path(workspace_dir) / self.control_socket_name
deadline = time.monotonic() + 5
while not control_socket.is_socket():
if not self._pid_alive(pid):
raise RuntimeError(f"bwrap process {pid} exited before control became ready")
if time.monotonic() >= deadline:
raise RuntimeError("bwrap owner control socket did not become ready")
time.sleep(0.05)
return {
"host": handle.get("host", "localhost"),
"endpoint": f"pid:{pid}",
}
def supports_execution(self) -> bool:
return True
def _validated_workspace(self, handle: dict[str, str]) -> Path:
sandbox_id = handle.get("sandbox_id", "")
if not sandbox_id or "/" in sandbox_id or sandbox_id in {".", ".."}:
raise RuntimeError("invalid sandbox id in execution handle")
workspace_value = handle.get("workspace_dir", "")
if not workspace_value:
raise RuntimeError("sandbox execution handle has no workspace")
workspace = Path(workspace_value).resolve(strict=True)
expected = (Path(self.base_dir).resolve() / sandbox_id).resolve()
if workspace != expected or not workspace.is_dir():
raise RuntimeError("refusing execution outside owner-managed sandbox workspace")
return workspace
def execute(
self,
handle: dict[str, str],
command: list[str],
*,
credential_route_refs: list[str],
execution_context: dict[str, str],
timeout_seconds: int,
max_output_bytes: int,
) -> dict[str, object]:
"""Ask the broker already inside bwrap to run an argument-vector command."""
pid = int(handle.get("pid", "0"))
if pid <= 0 or not self._pid_alive(pid):
raise RuntimeError(f"bwrap process {pid} is not running")
workspace = self._validated_workspace(handle)
request = {
"command": command,
"credential_route_refs": credential_route_refs,
"execution_context": execution_context,
"timeout_seconds": timeout_seconds,
"max_output_bytes": max_output_bytes,
}
response_limit = max_output_bytes * 2 + 65_536
chunks: list[bytes] = []
size = 0
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.settimeout(timeout_seconds + 5)
client.connect(str(workspace / self.control_socket_name))
client.sendall(json.dumps(request).encode("utf-8"))
client.shutdown(socket.SHUT_WR)
while True:
chunk = client.recv(65_536)
if not chunk:
break
size += len(chunk)
if size > response_limit:
raise RuntimeError("bwrap owner response exceeded its declared bound")
chunks.append(chunk)
response = json.loads(b"".join(chunks))
if "boundary_error" in response:
raise RuntimeError(f"bwrap owner command boundary failed: {response['boundary_error']}")
return response
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
pid_str = handle.get("pid", "")
killed = False
@ -113,10 +228,8 @@ class BwrapExtension(SandboxExtension):
try:
os.killpg(os.getpgid(pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
try:
with suppress(ProcessLookupError, PermissionError):
os.kill(pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError):
pass
killed = True
workspace_dir = handle.get("workspace_dir", "")

View file

@ -0,0 +1,99 @@
"""Minimal command broker launched inside an ext.bwrap namespace."""
from __future__ import annotations
import json
import os
import signal
import socket
import subprocess
import sys
from pathlib import Path
_MAX_REQUEST_BYTES = 1_048_576
def _bounded_output(value: bytes | None, limit: int) -> tuple[str, bool]:
raw = value or b""
truncated = len(raw) > limit
if truncated:
raw = raw[:limit]
return raw.decode("utf-8", errors="replace"), truncated
def _run(payload: dict, workspace: Path) -> dict[str, object]:
command = payload["command"]
timeout_seconds = int(payload["timeout_seconds"])
max_output_bytes = int(payload["max_output_bytes"])
credential_refs = payload.get("credential_route_refs", [])
context = payload.get("execution_context", {})
child_env = {
"HOME": str(workspace),
"LANG": "C.UTF-8",
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"SANDBOXER_CREDENTIAL_ROUTE_REFS": json.dumps(credential_refs),
**{f"SANDBOXER_{key.upper()}": value for key, value in context.items()},
}
timed_out = False
process = subprocess.Popen(
command,
cwd=workspace,
env=child_env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True,
)
try:
stdout_raw, stderr_raw = process.communicate(timeout=timeout_seconds)
exit_code = process.returncode
except subprocess.TimeoutExpired:
timed_out = True
os.killpg(process.pid, signal.SIGKILL)
stdout_raw, stderr_raw = process.communicate()
exit_code = 124
stdout, stdout_truncated = _bounded_output(stdout_raw, max_output_bytes)
stderr, stderr_truncated = _bounded_output(stderr_raw, max_output_bytes)
return {
"exit_code": exit_code,
"timed_out": timed_out,
"stdout": stdout,
"stderr": stderr,
"output_truncated": stdout_truncated or stderr_truncated,
"workspace_dir": str(workspace),
}
def main() -> int:
workspace = Path(sys.argv[1]).resolve(strict=True)
socket_path = Path(sys.argv[2])
socket_path.unlink(missing_ok=True)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
server.bind(str(socket_path))
socket_path.chmod(0o600)
server.listen(8)
while True:
connection, _ = server.accept()
with connection:
chunks: list[bytes] = []
size = 0
while True:
chunk = connection.recv(65_536)
if not chunk:
break
size += len(chunk)
if size > _MAX_REQUEST_BYTES:
chunks = []
break
chunks.append(chunk)
try:
if not chunks:
raise ValueError("empty or oversized execution request")
response = _run(json.loads(b"".join(chunks)), workspace)
except Exception as exc:
response = {"boundary_error": str(exc)}
connection.sendall(json.dumps(response).encode("utf-8"))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -25,6 +25,19 @@ class ExtensionBackend(Protocol):
def teardown(self, handle: dict[str, str]) -> dict[str, str]: ...
def supports_execution(self) -> bool: ...
def execute(
self,
handle: dict[str, str],
command: list[str],
*,
credential_route_refs: list[str],
execution_context: dict[str, str],
timeout_seconds: int,
max_output_bytes: int,
) -> dict[str, object]: ...
def extensions_dir() -> Path:
return _EXTENSIONS_DIR
@ -71,4 +84,4 @@ def resolve_backend(extension: Extension) -> ExtensionBackend:
raise ValueError(f"Invalid handler for {extension.id}: {extension.handler}")
module = importlib.import_module(module_path)
cls = getattr(module, attr)
return cls(extension.config)
return cls(extension.config)

View file

@ -147,6 +147,36 @@ class SandboxCreateRequest(BaseModel):
ttl: str | None = None
class SandboxExecRequest(BaseModel):
"""A command grant bound to the consumer recorded at sandbox creation."""
command: list[str] = Field(min_length=1, max_length=256)
consumer: Consumer
credential_route_refs: list[str] = Field(default_factory=list, max_length=32)
timeout_seconds: int = Field(default=900, ge=1, le=3600)
max_output_bytes: int = Field(default=262_144, ge=1, le=1_048_576)
class SandboxExecResult(BaseModel):
sandbox_id: str
profile_id: str
extension_id: str
consumer: Consumer
command_name: str
exit_code: int
timed_out: bool = False
stdout: str = ""
stderr: str = ""
output_truncated: bool = False
duration_seconds: float
workspace_dir: str
network_default: Literal["deny", "allow"]
network_egress: list[str] = Field(default_factory=list)
credential_route_refs: list[str] = Field(default_factory=list)
started_at: datetime
completed_at: datetime
class Reachability(BaseModel):
ssh: str | None = None
remote_dir: str | None = None
@ -211,4 +241,4 @@ class SnapshotRecord(BaseModel):
consumer: Consumer | None = None
name: str | None = None
size_bytes: int | None = None
created_at: datetime
created_at: datetime

View file

@ -2,4 +2,4 @@
from sandboxer.reachability.enrich import build_reachability_report, enrich_reachability
__all__ = ["enrich_reachability", "build_reachability_report"]
__all__ = ["enrich_reachability", "build_reachability_report"]

View file

@ -55,20 +55,6 @@ def ssh_one_liner(reach: Reachability) -> str | None:
return None
def local_exec_hint(reach: Reachability) -> str | None:
"""No-SSH-hop exec hint for same-host extensions (e.g. ext.bwrap).
Consumers exec directly into the sandbox's namespaces via the
placeholder process's pid, rather than opening an SSH channel.
"""
if reach.pid and reach.workspace_dir:
return (
f"nsenter --target {reach.pid} --mount --pid --net --uts --ipc "
f"-- sh -c 'cd {reach.workspace_dir} && exec $SHELL'"
)
return None
def build_reachability_report(status: SandboxStatus) -> dict[str, Any]:
"""Consumer-facing reachability report with ops-bridge pointer."""
reach = status.reachability
@ -84,5 +70,11 @@ def build_reachability_report(status: SandboxStatus) -> dict[str, Any]:
}
if reach:
payload["ssh_one_liner"] = ssh_one_liner(reach)
payload["local_exec_hint"] = local_exec_hint(reach)
return payload
if status.extension_id == "ext.bwrap":
payload["execution"] = {
"mode": "owner-mediated",
"cli": f"sandboxer exec {status.sandbox_id} [identity options] -- COMMAND...",
"api": f"POST /v1/sandboxes/{status.sandbox_id}/exec",
"note": "Direct namespace entry is unsupported; invoke the sand-boxer owner",
}
return payload

View file

@ -5,7 +5,14 @@ from unittest.mock import patch
from fastapi.testclient import TestClient
from sandboxer.api.app import app
from sandboxer.models import ActorType, Consumer, SandboxState, SandboxStatus, SnapshotRecord
from sandboxer.models import (
ActorType,
Consumer,
SandboxExecResult,
SandboxState,
SandboxStatus,
SnapshotRecord,
)
def test_list_sandboxes_empty() -> None:
@ -148,4 +155,59 @@ def test_expire_sandboxes() -> None:
client = TestClient(app)
resp = client.post("/v1/sandboxes/expire")
assert resp.status_code == 200
assert resp.json()[0]["action"] == "dry-run"
assert resp.json()[0]["action"] == "dry-run"
def test_exec_api_fails_closed_when_owner_token_unconfigured(monkeypatch) -> None:
monkeypatch.delenv("SANDBOXER_EXEC_TOKEN", raising=False)
client = TestClient(app)
resp = client.post(
"/v1/sandboxes/abc12345/exec",
json={
"command": ["true"],
"consumer": {"actor": "agt", "project": "glas-harness", "run_id": "run-1"},
},
)
assert resp.status_code == 503
def test_exec_api_requires_bearer_and_returns_execution(monkeypatch) -> None:
from datetime import UTC, datetime
monkeypatch.setenv("SANDBOXER_EXEC_TOKEN", "owner-capability")
now = datetime.now(UTC)
consumer = Consumer(actor="agt", project="glas-harness", run_id="run-1")
result = SandboxExecResult(
sandbox_id="abc12345",
profile_id="profile.bwrap-local",
extension_id="ext.bwrap",
consumer=consumer,
command_name="true",
exit_code=0,
duration_seconds=0.1,
workspace_dir="/tmp/sandboxer-bwrap/abc12345",
network_default="deny",
started_at=now,
completed_at=now,
)
payload = {
"command": ["true"],
"consumer": consumer.model_dump(mode="json"),
}
with patch("sandboxer.api.app._manager") as mgr:
mgr.execute.return_value = result
client = TestClient(app)
denied = client.post(
"/v1/sandboxes/abc12345/exec",
json=payload,
headers={"Authorization": "Bearer wrong"},
)
allowed = client.post(
"/v1/sandboxes/abc12345/exec",
json=payload,
headers={"Authorization": "Bearer owner-capability"},
)
assert denied.status_code == 401
assert allowed.status_code == 200
assert allowed.json()["exit_code"] == 0

View file

@ -1,12 +1,20 @@
"""ext.bwrap — local namespace isolation extension."""
import json
import signal
import socket
import stat
import subprocess
import threading
from unittest.mock import MagicMock, patch
import pytest
from sandboxer.extensions.base import SandboxExtension
from sandboxer.extensions.bwrap import BwrapExtension
from sandboxer.models import IsolationSpec, Profile
from sandboxer.extensions.bwrap_runner import _run
from sandboxer.models import IsolationSpec, Profile, Reachability
from sandboxer.reachability.enrich import enrich_reachability
def _profile() -> Profile:
@ -40,7 +48,13 @@ def test_bwrap_argv_unshares_net_and_binds_workspace(tmp_path) -> None:
assert "--unshare-net" in argv
assert "--unshare-user" in argv
assert str(tmp_path) in argv
assert argv[-2:] == ["sleep", "infinity"]
assert "/run/sandboxer/bwrap_runner.py" in argv
assert argv[-4:] == [
"/usr/bin/python3",
"/run/sandboxer/bwrap_runner.py",
str(tmp_path),
f"{tmp_path}/.sandboxer-owner.sock",
]
def test_existing_ro_binds_filters_missing_paths(tmp_path) -> None:
@ -55,13 +69,17 @@ def test_provision_spawns_bwrap_and_returns_handle(tmp_path) -> None:
fake_proc = MagicMock()
fake_proc.pid = 12345
with patch("sandboxer.extensions.bwrap.subprocess.Popen", return_value=fake_proc) as popen:
with (
patch("sandboxer.extensions.bwrap.subprocess.Popen", return_value=fake_proc) as popen,
patch.object(BwrapExtension, "_read_child_pid", return_value=23456),
):
handle = ext.provision(_profile(), {"sandbox_id": "abc12345"}, "localhost")
popen.assert_called_once()
assert handle["sandbox_id"] == "abc12345"
assert handle["host"] == "localhost"
assert handle["pid"] == "12345"
assert handle["pid"] == "23456"
assert handle["supervisor_pid"] == "12345"
assert handle["workspace_dir"].endswith("abc12345")
@ -75,13 +93,17 @@ def test_provision_copies_repo_into_workspace(tmp_path) -> None:
fake_proc = MagicMock()
fake_proc.pid = 1
with patch("sandboxer.extensions.bwrap.subprocess.Popen", return_value=fake_proc):
with (
patch("sandboxer.extensions.bwrap.subprocess.Popen", return_value=fake_proc),
patch.object(BwrapExtension, "_read_child_pid", return_value=2),
):
handle = ext.provision(
_profile(), {"sandbox_id": "cafe1234", "repo": str(repo)}, "localhost"
)
assert (base_dir / "cafe1234" / "file.txt").read_text() == "hello"
assert handle["workspace_dir"] == str(base_dir / "cafe1234")
assert stat.S_IMODE((base_dir / "cafe1234").stat().st_mode) == 0o700
def test_provision_missing_repo_raises(tmp_path) -> None:
@ -100,7 +122,10 @@ def test_wait_ready_checks_process_and_workspace(tmp_path) -> None:
ext = BwrapExtension()
handle = {"pid": str(1), "workspace_dir": str(workspace), "host": "localhost"}
with patch.object(BwrapExtension, "_pid_alive", return_value=True):
with (
patch.object(BwrapExtension, "_pid_alive", return_value=True),
patch("sandboxer.extensions.bwrap.Path.is_socket", return_value=True),
):
result = ext.wait_ready(handle)
assert result["host"] == "localhost"
@ -113,18 +138,22 @@ def test_wait_ready_raises_if_process_dead(tmp_path) -> None:
ext = BwrapExtension()
handle = {"pid": "999999", "workspace_dir": str(workspace), "host": "localhost"}
with patch.object(BwrapExtension, "_pid_alive", return_value=False):
with pytest.raises(RuntimeError, match="not running"):
ext.wait_ready(handle)
with (
patch.object(BwrapExtension, "_pid_alive", return_value=False),
pytest.raises(RuntimeError, match="not running"),
):
ext.wait_ready(handle)
def test_wait_ready_raises_if_workspace_missing(tmp_path) -> None:
ext = BwrapExtension()
handle = {"pid": "1", "workspace_dir": str(tmp_path / "gone"), "host": "localhost"}
with patch.object(BwrapExtension, "_pid_alive", return_value=True):
with pytest.raises(RuntimeError, match="workspace missing"):
ext.wait_ready(handle)
with (
patch.object(BwrapExtension, "_pid_alive", return_value=True),
pytest.raises(RuntimeError, match="workspace missing"),
):
ext.wait_ready(handle)
def test_teardown_kills_process_group_and_removes_workspace(tmp_path) -> None:
@ -166,10 +195,7 @@ def test_supports_snapshots_is_false() -> None:
ext.snapshot({})
def test_reachability_local_exec_hint_for_bwrap_handle() -> None:
from sandboxer.reachability.enrich import enrich_reachability, local_exec_hint
from sandboxer.models import Reachability
def test_reachability_enriches_bwrap_handle_without_direct_exec_hint() -> None:
handle = {"pid": "555", "workspace_dir": "/tmp/sandboxer-bwrap/abc", "host": "localhost"}
reach = {"host": "localhost", "endpoint": "pid:555"}
profile = _profile()
@ -179,7 +205,147 @@ def test_reachability_local_exec_hint_for_bwrap_handle() -> None:
assert reachability.pid == "555"
assert reachability.workspace_dir == "/tmp/sandboxer-bwrap/abc"
assert local_exec_hint(reachability) == (
"nsenter --target 555 --mount --pid --net --uts --ipc "
"-- sh -c 'cd /tmp/sandboxer-bwrap/abc && exec $SHELL'"
)
def test_execute_sends_bounded_request_to_in_namespace_owner(tmp_path) -> None:
base_dir = tmp_path / "sandboxes"
workspace = base_dir / "abc12345"
workspace.mkdir(parents=True)
ext = BwrapExtension({"base_dir": str(base_dir)})
handle = {
"sandbox_id": "abc12345",
"pid": "42",
"workspace_dir": str(workspace),
}
control = workspace / ".sandboxer-owner.sock"
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(str(control))
server.listen(1)
received = {}
def serve_once() -> None:
connection, _ = server.accept()
with connection:
raw = b""
while chunk := connection.recv(65_536):
raw += chunk
received.update(json.loads(raw))
connection.sendall(
json.dumps(
{
"exit_code": 0,
"timed_out": False,
"stdout": "ok\n",
"stderr": "",
"output_truncated": False,
"workspace_dir": str(workspace),
}
).encode()
)
thread = threading.Thread(target=serve_once)
thread.start()
with patch.object(BwrapExtension, "_pid_alive", return_value=True):
result = ext.execute(
handle,
["python3", "-V"],
credential_route_refs=["rein-openweights-openrouter-approle"],
execution_context={"actor": "agt", "run_id": "run-1"},
timeout_seconds=30,
max_output_bytes=1024,
)
thread.join(timeout=2)
server.close()
assert received["command"] == ["python3", "-V"]
assert received["execution_context"] == {"actor": "agt", "run_id": "run-1"}
assert received["credential_route_refs"] == ["rein-openweights-openrouter-approle"]
assert received["timeout_seconds"] == 30
assert received["max_output_bytes"] == 1024
assert result["stdout"] == "ok\n"
assert result["exit_code"] == 0
def test_in_namespace_runner_uses_sanitized_environment(tmp_path) -> None:
process = MagicMock()
process.returncode = 0
process.communicate.return_value = (b"ok\n", b"")
payload = {
"command": ["python3", "-V"],
"credential_route_refs": ["rein-openweights-openrouter-approle"],
"execution_context": {"actor": "agt", "run_id": "run-1"},
"timeout_seconds": 30,
"max_output_bytes": 1024,
}
with patch(
"sandboxer.extensions.bwrap_runner.subprocess.Popen", return_value=process
) as popen:
result = _run(payload, tmp_path)
assert popen.call_args.args[0] == ["python3", "-V"]
assert popen.call_args.kwargs["cwd"] == tmp_path
child_env = popen.call_args.kwargs["env"]
assert child_env["SANDBOXER_ACTOR"] == "agt"
assert child_env["SANDBOXER_RUN_ID"] == "run-1"
assert "rein-openweights-openrouter-approle" in child_env[
"SANDBOXER_CREDENTIAL_ROUTE_REFS"
]
assert set(child_env) == {
"HOME",
"LANG",
"PATH",
"SANDBOXER_CREDENTIAL_ROUTE_REFS",
"SANDBOXER_ACTOR",
"SANDBOXER_RUN_ID",
}
assert result["stdout"] == "ok\n"
def test_execute_refuses_workspace_outside_owner_base(tmp_path) -> None:
outside = tmp_path / "source-checkout"
outside.mkdir()
ext = BwrapExtension({"base_dir": str(tmp_path / "sandboxes")})
handle = {"sandbox_id": "abc12345", "pid": "42", "workspace_dir": str(outside)}
with (
patch.object(BwrapExtension, "_pid_alive", return_value=True),
pytest.raises(RuntimeError, match="outside owner-managed"),
):
ext.execute(
handle,
["true"],
credential_route_refs=[],
execution_context={},
timeout_seconds=30,
max_output_bytes=1024,
)
def test_runner_bounds_output_and_normalizes_timeout(tmp_path) -> None:
process = MagicMock()
process.pid = 99
process.communicate.side_effect = [
subprocess.TimeoutExpired("cmd", 1),
(b"123456", b"abcdef"),
]
payload = {
"command": ["sleep", "2"],
"credential_route_refs": [],
"execution_context": {},
"timeout_seconds": 1,
"max_output_bytes": 3,
}
with (
patch("sandboxer.extensions.bwrap_runner.subprocess.Popen", return_value=process),
patch("sandboxer.extensions.bwrap_runner.os.killpg") as killpg,
):
result = _run(payload, tmp_path)
killpg.assert_called_once_with(99, signal.SIGKILL)
assert result["exit_code"] == 124
assert result["timed_out"] is True
assert result["stdout"] == "123"
assert result["stderr"] == "abc"
assert result["output_truncated"] is True

View file

@ -2,15 +2,23 @@
from __future__ import annotations
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from pathlib import Path
from threading import Lock
from unittest.mock import patch
import pytest
from sandboxer.core.manager import SandboxManager
from sandboxer.lifecycle.store import SandboxStore
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest, SandboxState, SandboxStatus
from sandboxer.models import (
ActorType,
Consumer,
SandboxCreateRequest,
SandboxExecRequest,
SandboxState,
SandboxStatus,
)
class FakeBackend:
@ -40,6 +48,26 @@ class FakeBackend:
}
class FakeExecBackend:
def supports_execution(self):
return True
def execute(self, handle, command, **kwargs):
return {
"exit_code": 0,
"timed_out": False,
"stdout": "inside\n",
"stderr": "",
"output_truncated": False,
"workspace_dir": handle["workspace_dir"],
}
class FakeNonExecBackend:
def supports_execution(self):
return False
@pytest.fixture
def store(tmp_path: Path) -> SandboxStore:
return SandboxStore(path=tmp_path / "sandboxes.json")
@ -82,4 +110,133 @@ def test_destroy_idempotent(store: SandboxStore) -> None:
store.save(status)
manager = SandboxManager(store=store)
result = manager.destroy("gone1234")
assert result.state == SandboxState.DESTROYED
assert result.state == SandboxState.DESTROYED
def _ready_bwrap_status(*, state: SandboxState = SandboxState.READY) -> SandboxStatus:
now = datetime.now(UTC)
return SandboxStatus(
sandbox_id="bwrap123",
profile_id="profile.bwrap-local",
extension_id="ext.bwrap",
state=state,
consumer=Consumer(
actor=ActorType.AGT,
project="glas-harness",
session_id="session-1",
run_id="run-1",
),
host="localhost",
inputs={"pid": "42", "workspace_dir": "/tmp/sandboxer-bwrap/bwrap123"},
expires_at=now + timedelta(hours=1),
created_at=now,
updated_at=now,
ready_at=now,
)
def _exec_request(**consumer_overrides) -> SandboxExecRequest:
consumer = {
"actor": "agt",
"project": "glas-harness",
"session_id": "session-1",
"run_id": "run-1",
**consumer_overrides,
}
return SandboxExecRequest(
command=["python3", "-V"],
consumer=Consumer.model_validate(consumer),
credential_route_refs=["rein-openweights-openrouter-approle"],
timeout_seconds=30,
)
def test_execute_binds_identity_and_restores_ready_state(store: SandboxStore) -> None:
store.save(_ready_bwrap_status())
manager = SandboxManager(store=store)
backend = FakeExecBackend()
with (
patch("sandboxer.core.manager.resolve_backend", return_value=backend),
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
):
result = manager.execute("bwrap123", _exec_request())
assert result.exit_code == 0
assert result.stdout == "inside\n"
assert result.network_default == "deny"
assert result.network_egress == []
assert result.credential_route_refs == ["rein-openweights-openrouter-approle"]
assert store.get("bwrap123").state == SandboxState.READY
def test_execute_refuses_identity_mismatch(store: SandboxStore) -> None:
store.save(_ready_bwrap_status())
manager = SandboxManager(store=store)
with pytest.raises(PermissionError, match="exactly match"):
manager.execute("bwrap123", _exec_request(run_id="another-run"))
def test_execute_refuses_non_ready_and_expired_sandboxes(store: SandboxStore) -> None:
active = _ready_bwrap_status(state=SandboxState.ACTIVE)
store.save(active)
manager = SandboxManager(store=store)
with pytest.raises(RuntimeError, match="must be ready"):
manager.execute("bwrap123", _exec_request())
def test_execute_refuses_concurrent_command(store: SandboxStore) -> None:
store.save(_ready_bwrap_status())
manager = SandboxManager(store=store)
lock = manager._exec_locks.setdefault("bwrap123", Lock())
lock.acquire()
try:
with pytest.raises(RuntimeError, match="already has an active"):
manager.execute("bwrap123", _exec_request())
finally:
lock.release()
expired = _ready_bwrap_status()
expired.expires_at = datetime.now(UTC) - timedelta(seconds=1)
store.save(expired)
with pytest.raises(RuntimeError, match="TTL has expired"):
manager.execute("bwrap123", _exec_request())
def test_execute_refuses_invalid_credential_route_ref(store: SandboxStore) -> None:
store.save(_ready_bwrap_status())
request = _exec_request()
request.credential_route_refs = ["looks like a secret value"]
with pytest.raises(ValueError, match="value-free catalog"):
SandboxManager(store=store).execute("bwrap123", request)
def test_execute_refuses_extension_without_owner_boundary(store: SandboxStore) -> None:
status = _ready_bwrap_status()
status.profile_id = "profile.compose-e2e"
status.extension_id = "ext.compose-ssh"
store.save(status)
with (
patch("sandboxer.core.manager.resolve_backend", return_value=FakeNonExecBackend()),
pytest.raises(RuntimeError, match="no owner-mediated execution boundary"),
):
SandboxManager(store=store).execute("bwrap123", _exec_request())
def test_execute_restores_ready_after_backend_failure(store: SandboxStore) -> None:
store.save(_ready_bwrap_status())
manager = SandboxManager(store=store)
backend = FakeExecBackend()
backend.execute = lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom"))
with (
patch("sandboxer.core.manager.resolve_backend", return_value=backend),
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
pytest.raises(RuntimeError, match="boom"),
):
manager.execute("bwrap123", _exec_request())
assert store.get("bwrap123").state == SandboxState.READY

View file

@ -75,4 +75,30 @@ def test_build_reachability_report() -> None:
report = build_reachability_report(status)
assert report["sandbox_id"] == "abc12345"
assert report["ssh_one_liner"] is not None
assert "ops_bridge" in report
assert "ops_bridge" in report
def test_bwrap_report_points_to_owner_execution_not_nsenter() -> None:
now = datetime.now(UTC)
status = SandboxStatus(
sandbox_id="bwrap123",
profile_id="profile.bwrap-local",
extension_id="ext.bwrap",
state=SandboxState.READY,
consumer=Consumer(actor=ActorType.AGT, project="glas-harness", run_id="run-1"),
host="localhost",
reachability=Reachability(
host="localhost",
endpoint="pid:42",
pid="42",
workspace_dir="/tmp/sandboxer-bwrap/bwrap123",
),
created_at=now,
updated_at=now,
)
report = build_reachability_report(status)
assert report["execution"]["mode"] == "owner-mediated"
assert "nsenter" not in str(report)
assert "local_exec_hint" not in report

View file

@ -71,7 +71,7 @@ def test_resolve_initial_ttl() -> None:
def test_extend_expires_at_caps_at_max() -> None:
anchor = datetime(2026, 6, 24, 10, 0, tzinfo=UTC)
anchor = datetime.now(UTC)
current = anchor + timedelta(hours=23)
new_expires, applied = extend_expires_at(
current,
@ -262,4 +262,4 @@ def test_manager_expire_dry_run_and_apply(store: SandboxStore) -> None:
applied = manager.expire(apply=True, now=now)
assert applied[0].action == "destroyed"
assert manager.get("gone5678").state == SandboxState.DESTROYED
assert manager.get("gone5678").state == SandboxState.DESTROYED

View file

@ -0,0 +1,121 @@
---
id: SAND-WP-0014
type: workplan
title: "Owner-mediated governed bwrap execution"
domain: infotech
repo: sand-boxer
status: active
owner: codex
topic_slug: owner-mediated-execution
created: "2026-09-04"
updated: "2026-09-04"
state_hub_workstream_id: "b616d1cd-208f-5ecf-a4a0-a028396422c4"
---
# Owner-mediated governed bwrap execution
Promote intake `GLAS-IN-0002` into sand-boxer ownership. Replace the unusable
consumer-side `nsenter` hint with an owner-executed command boundary that remains
bound to the sandbox and its governed consumer identity. A refused execution
must never fall back to a host checkout or another extension.
## Define the governed execution contract
```task
id: SAND-WP-0014-T01
status: done
priority: high
state_hub_task_id: "1b4349de-7027-5f01-8deb-122772e1404d"
```
Define request/result schemas and evidence for exact actor/project/session/run
binding, ready/active lifecycle, workspace confinement, declared network
posture, value-free credential route references, bounded command duration, and
bounded output.
## Implement owner-mediated bwrap execution
```task
id: SAND-WP-0014-T02
status: done
priority: high
state_hub_task_id: "59f9af7c-7cd7-5e86-849f-5dfc7a3a06ee"
```
Add manager, extension, CLI, and authenticated HTTP surfaces. Only `ext.bwrap`
may execute in this slice. The extension launches an owner broker inside bwrap,
uses the sandbox workspace as its working directory, starts commands with a sanitized
environment, and fails closed if the recorded workspace is not the exact
owner-managed sandbox directory.
## Remove direct namespace-entry guidance
```task
id: SAND-WP-0014-T03
status: done
priority: high
state_hub_task_id: "4fc7d747-13d5-58e7-b89e-b72e845515aa"
```
Stop publishing raw consumer-side `nsenter` commands. Reachability describes
owner-mediated execution without exposing a misleading direct-attach route.
## Verify unit and local namespace behavior
```task
id: SAND-WP-0014-T04
status: done
priority: high
state_hub_task_id: "c7ada937-4430-59d7-b3a6-9f3f36e9a907"
```
Cover identity/state/TTL refusal, unsupported-extension refusal, exact workspace
validation, sanitized environment, credential route references, timeout/output
bounds, API authentication, lifecycle restoration, and teardown. Run the full
repository check and, when host user namespaces permit it, a non-secret live
bwrap command proof.
Implemented and verified 2026-09-04. `make check` passes with 119 tests. The
first live attempt proved that even the bwrap owner cannot reliably `setns`
from the host on this kernel (`IPC: Operation not permitted`), so execution was
moved to a small read-only broker launched inside the namespace and reached via
an owner-only Unix socket. `make smoke-bwrap-exec` then passed: actor `agt`,
project/session/run binding reached the command; cwd was the copied sandbox;
the host-source sentinel was absent; the command created an artifact; the net
namespace exposed only `lo` under declared `default: deny`, `egress: []`; no
credential routes or values were present; and teardown reported `destroyed`
with the workspace removed.
## Prove one governed rein and coordinate consumers
```task
id: SAND-WP-0014-T05
status: wait
priority: high
state_hub_task_id: "c6812fd4-bb7b-5be0-8344-a122496fe5bc"
```
With Glas and rein-aharness, run one real selected rein command through the
owner API using an explicitly declared egress profile and catalog credential
route. Prove that the source checkout is absent, retain value-free evidence,
destroy the workspace, then update Glas readiness and Activity Core
`ACTIVITY-WP-0032-T05`. Do not trigger the production pilot before readiness
changes.
This task depends on a reviewed Glas profile revision, the owner-fronted
`rein-openweights-openrouter-approle` read, and a deployed sand-boxer owner
service. No credential value belongs in this workplan or State Hub.
## Acceptance criteria
- Execution requires the recorded actor, project, session, and run identity.
- Only a live, unexpired, ready sandbox can start a command; concurrent commands
are refused.
- Command execution is argument-vector based, starts inside the copied sandbox
workspace, and has no host/source-checkout fallback.
- `ext.bwrap` supplies its declared read-only runtime paths and enforces the
profile's network namespace. Evidence reports default policy and egress list.
- Credential inputs are non-secret catalog route references only.
- Duration and captured output are bounded, lifecycle state is restored, and
teardown remains idempotent.
- Consumer-facing reachability no longer recommends direct `nsenter`.