From d79e3fe3587da36b69be7dc95680b1c04b0cb909 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 4 Sep 2026 22:12:19 +0200 Subject: [PATCH] Add owner-mediated bwrap execution boundary Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a06def-6490-7033-8448-2eab2d12ed44 --- Makefile | 7 +- WORK-RECORDS.md | 6 + docs/extension-sdk.md | 11 +- docs/integrations/glas-harness.md | 30 ++- docs/meta-framework.md | 18 +- docs/runbooks/profile-bwrap-local.md | 84 +++++++ scripts/smoke-bwrap-exec.py | 88 ++++++++ src/sandboxer/api/app.py | 38 +++- src/sandboxer/cli.py | 60 ++++- src/sandboxer/core/manager.py | 143 +++++++++++- src/sandboxer/extensions/base.py | 19 +- src/sandboxer/extensions/bwrap.py | 143 ++++++++++-- src/sandboxer/extensions/bwrap_runner.py | 99 +++++++++ src/sandboxer/extensions/registry.py | 15 +- src/sandboxer/models.py | 32 ++- src/sandboxer/reachability/__init__.py | 2 +- src/sandboxer/reachability/enrich.py | 24 +- tests/test_api.py | 66 +++++- tests/test_bwrap.py | 206 ++++++++++++++++-- tests/test_manager.py | 163 +++++++++++++- tests/test_reachability.py | 28 ++- tests/test_ttl.py | 4 +- .../SAND-WP-0014-owner-mediated-execution.md | 121 ++++++++++ 23 files changed, 1321 insertions(+), 86 deletions(-) create mode 100644 docs/runbooks/profile-bwrap-local.md create mode 100644 scripts/smoke-bwrap-exec.py create mode 100644 src/sandboxer/extensions/bwrap_runner.py create mode 100644 workplans/SAND-WP-0014-owner-mediated-execution.md diff --git a/Makefile b/Makefile index 03c6172..beee4c0 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 +.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 \ No newline at end of file + ./scripts/smoke-compose-e2e.sh + +smoke-bwrap-exec: ## Owner-mediated local bwrap create/exec/destroy proof + uv run python scripts/smoke-bwrap-exec.py diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index c58cede..dc2f581 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/docs/extension-sdk.md b/docs/extension-sdk.md index 6a9be64..e123b47 100644 --- a/docs/extension-sdk.md +++ b/docs/extension-sdk.md @@ -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 | \ No newline at end of file +| Cross-host snapshot transfer | Future | diff --git a/docs/integrations/glas-harness.md b/docs/integrations/glas-harness.md index 6860bb8..6b8d464 100644 --- a/docs/integrations/glas-harness.md +++ b/docs/integrations/glas-harness.md @@ -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 --mount --pid --net --uts --ipc -- `, 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 \ No newline at end of file +- Subagent orchestration +- Provider credential acquisition or injection (the selected rein owns it) diff --git a/docs/meta-framework.md b/docs/meta-framework.md index 4413e87..1d51606 100644 --- a/docs/meta-framework.md +++ b/docs/meta-framework.md @@ -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. \ No newline at end of file +See `docs/integrations/` for per-sibling contracts. diff --git a/docs/runbooks/profile-bwrap-local.md b/docs/runbooks/profile-bwrap-local.md new file mode 100644 index 0000000..b5a2376 --- /dev/null +++ b/docs/runbooks/profile-bwrap-local.md @@ -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 +``` diff --git a/scripts/smoke-bwrap-exec.py b/scripts/smoke-bwrap-exec.py new file mode 100644 index 0000000..592a1dd --- /dev/null +++ b/scripts/smoke-bwrap-exec.py @@ -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()) diff --git a/src/sandboxer/api/app.py b/src/sandboxer/api/app.py index 38d1c94..75b1cbc 100644 --- a/src/sandboxer/api/app.py +++ b/src/sandboxer/api/app.py @@ -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) \ No newline at end of file + return _manager.expire(apply=apply) diff --git a/src/sandboxer/cli.py b/src/sandboxer/cli.py index e79a352..a6ce904 100644 --- a/src/sandboxer/cli.py +++ b/src/sandboxer/cli.py @@ -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() \ No newline at end of file + app() diff --git a/src/sandboxer/core/manager.py b/src/sandboxer/core/manager.py index 9989009..9a01968 100644 --- a/src/sandboxer/core/manager.py +++ b/src/sandboxer/core/manager.py @@ -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 \ No newline at end of file + raise diff --git a/src/sandboxer/extensions/base.py b/src/sandboxer/extensions/base.py index 19ac9ad..7df3ebf 100644 --- a/src/sandboxer/extensions/base.py +++ b/src/sandboxer/extensions/base.py @@ -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") \ No newline at end of file + raise NotImplementedError(f"{type(self).__name__} does not support restore") diff --git a/src/sandboxer/extensions/bwrap.py b/src/sandboxer/extensions/bwrap.py index 9fccc75..47f29fa 100644 --- a/src/sandboxer/extensions/bwrap.py +++ b/src/sandboxer/extensions/bwrap.py @@ -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", "") diff --git a/src/sandboxer/extensions/bwrap_runner.py b/src/sandboxer/extensions/bwrap_runner.py new file mode 100644 index 0000000..cc7fac4 --- /dev/null +++ b/src/sandboxer/extensions/bwrap_runner.py @@ -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()) diff --git a/src/sandboxer/extensions/registry.py b/src/sandboxer/extensions/registry.py index 7825f73..43e070a 100644 --- a/src/sandboxer/extensions/registry.py +++ b/src/sandboxer/extensions/registry.py @@ -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) \ No newline at end of file + return cls(extension.config) diff --git a/src/sandboxer/models.py b/src/sandboxer/models.py index 688414f..60b597e 100644 --- a/src/sandboxer/models.py +++ b/src/sandboxer/models.py @@ -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 \ No newline at end of file + created_at: datetime diff --git a/src/sandboxer/reachability/__init__.py b/src/sandboxer/reachability/__init__.py index e618af0..169c072 100644 --- a/src/sandboxer/reachability/__init__.py +++ b/src/sandboxer/reachability/__init__.py @@ -2,4 +2,4 @@ from sandboxer.reachability.enrich import build_reachability_report, enrich_reachability -__all__ = ["enrich_reachability", "build_reachability_report"] \ No newline at end of file +__all__ = ["enrich_reachability", "build_reachability_report"] diff --git a/src/sandboxer/reachability/enrich.py b/src/sandboxer/reachability/enrich.py index 89a1efe..f9a70eb 100644 --- a/src/sandboxer/reachability/enrich.py +++ b/src/sandboxer/reachability/enrich.py @@ -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 \ No newline at end of file + 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 diff --git a/tests/test_api.py b/tests/test_api.py index 6c23061..e01fa95 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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" \ No newline at end of file + 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 diff --git a/tests/test_bwrap.py b/tests/test_bwrap.py index 0810945..bfa0de9 100644 --- a/tests/test_bwrap.py +++ b/tests/test_bwrap.py @@ -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 diff --git a/tests/test_manager.py b/tests/test_manager.py index 43551b1..18b5302 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -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 \ No newline at end of file + 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 diff --git a/tests/test_reachability.py b/tests/test_reachability.py index b936221..7b8d603 100644 --- a/tests/test_reachability.py +++ b/tests/test_reachability.py @@ -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 \ No newline at end of file + 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 diff --git a/tests/test_ttl.py b/tests/test_ttl.py index a47f555..99e732b 100644 --- a/tests/test_ttl.py +++ b/tests/test_ttl.py @@ -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 \ No newline at end of file + assert manager.get("gone5678").state == SandboxState.DESTROYED diff --git a/workplans/SAND-WP-0014-owner-mediated-execution.md b/workplans/SAND-WP-0014-owner-mediated-execution.md new file mode 100644 index 0000000..19a2b2d --- /dev/null +++ b/workplans/SAND-WP-0014-owner-mediated-execution.md @@ -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`.