From bfe0e4c4c86003892796ba0fb990ae21d72278e1 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 9 Sep 2026 22:20:56 +0200 Subject: [PATCH] Confine metered runs to an ephemeral owner Messages route Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- docs/bwrap-messages-route.md | 34 ++++++++++ src/sandboxer/core/manager.py | 16 +++++ src/sandboxer/extensions/bwrap.py | 19 +++++- src/sandboxer/extensions/bwrap_runner.py | 24 ++++++- src/sandboxer/extensions/messages_route.py | 65 +++++++++++++++++++ tests/test_messages_route.py | 64 ++++++++++++++++++ ...WP-0015-bwrap-runtime-and-private-state.md | 14 +++- 7 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 docs/bwrap-messages-route.md create mode 100644 src/sandboxer/extensions/messages_route.py create mode 100644 tests/test_messages_route.py diff --git a/docs/bwrap-messages-route.md b/docs/bwrap-messages-route.md new file mode 100644 index 0000000..b9df313 --- /dev/null +++ b/docs/bwrap-messages-route.md @@ -0,0 +1,34 @@ +# Owner-metered Messages route + +An explicitly supplied `SandboxManager(messages_route=OwnerMessagesRoute(...))` +accepts one exact profile/actor/project/run binding and one sandbox. This trusted +in-process capability is never loaded from a profile, caller input or stored status. +Rein's `MessagesOwner` constructs it after parent spend admission, using llm-connect's +private Unix listener. Only that socket is mounted at `/run/sandboxer/messages.sock`. +The existing bounded byte bridge exposes a namespace-local HTTP base URL. The +broker injects an opaque run token, with exact-output redaction, into each child. +The actual provider key stays in the trusted forwarding process outside bwrap. + +Metered mode requires ext.bwrap, default-deny with an empty egress list, no owner +CONNECT allowlist, no setup secrets or provider credential routes, and only standard +system read-only mounts. Private owner state cannot overlap source/workspace/runtime +or those mounts. No provider key/ledger directory or host network interface is +exposed. Workload changes to URLs, proxy variables, Git helpers or HTTP paths cannot +create another route. Raw access to the mounted socket still reaches the same +metered protocol. Host owner code remains trusted; this adds no public API authority. + +Route expiry, ledger admission and revocation belong to rein. Sand-boxer owns +namespace confinement and teardown. Tokens are absent from argv and persisted +SandboxStatus/inputs. A manager without the ephemeral binding cannot inject the +required token into an existing metered broker after a restart. Unknown request +liability is retained; bootstrap/recovery must not rebind or mint replacement tokens. + +The local proof in rein `tests/test_messages_owner.py` uses the actual manager, +owner execution transport, bwrap namespaces and host-side fake provider. It proves +private state and PID separation, only lo, blocked direct host/public-IP access, +successful guarded streaming, and no second upstream call after revocation. The +combined worker proof also covers allowed commit import and lost-close replay. +`make check`: lint clean, 199 passed. No runtime/profile installation or real secret +read occurred. Existing direct-CONNECT profiles/credential delivery remain separate; +the factory needs a newly reviewed empty-egress profile and admitted owner bootstrap +on Railiance, under SAND-WP-0015-T04 and HFACT-WP-0001-T03/T04. diff --git a/src/sandboxer/core/manager.py b/src/sandboxer/core/manager.py index af41c14..51202bb 100644 --- a/src/sandboxer/core/manager.py +++ b/src/sandboxer/core/manager.py @@ -6,6 +6,7 @@ import re from pathlib import Path from threading import Lock +from sandboxer.extensions.messages_route import OwnerMessagesRoute from sandboxer.extensions.registry import load_extension, resolve_backend from sandboxer.lifecycle.expire import ( ExpireCandidate, @@ -52,10 +53,14 @@ class SandboxManager: store: SandboxStore | None = None, credits: CreditsStore | None = None, snapshots: SnapshotStore | None = None, + *, + messages_route: OwnerMessagesRoute | None = None, ) -> None: self.store = store or SandboxStore() self.credits = credits or CreditsStore() self.snapshots = snapshots or SnapshotStore() + self._messages_route = messages_route + self._messages_created = False self._exec_locks: dict[str, Lock] = {} @staticmethod @@ -101,6 +106,12 @@ class SandboxManager: profile = load_profile(request.profile) extension = resolve_extension(profile, request.inputs, host_override=host) backend = resolve_backend(extension) + if self._messages_route is not None: + self._messages_route.validate(profile, request.consumer, backend, request.inputs) + if self._messages_created: + raise ValueError("metered owner manager is single-run and single-sandbox") + self._messages_created = True + backend.messages_route = self._messages_route resolved_host = self._resolved_host(profile, extension, host) wants_telemetry = profile_wants_telemetry(profile) base_dir = extension.config.get("base_dir", "/tmp/sandboxer") @@ -276,6 +287,11 @@ class SandboxManager: profile = load_profile(status.profile_id) extension = load_extension(status.extension_id) backend = resolve_backend(extension) + if self._messages_route is not None: + self._messages_route.validate(profile, status.consumer, backend, status.inputs) + if request.credential_route_refs: + raise ValueError("metered execution refuses provider credential delivery") + backend.messages_route = self._messages_route if not backend.supports_execution(): raise RuntimeError( f"Extension {status.extension_id} has no owner-mediated execution boundary" diff --git a/src/sandboxer/extensions/bwrap.py b/src/sandboxer/extensions/bwrap.py index eb73b4c..ad1949e 100644 --- a/src/sandboxer/extensions/bwrap.py +++ b/src/sandboxer/extensions/bwrap.py @@ -17,6 +17,7 @@ from typing import Any from sandboxer.extensions.base import SandboxExtension from sandboxer.extensions.egress import destinations +from sandboxer.extensions.messages_route import OwnerMessagesRoute from sandboxer.extensions.runtime import RUNTIME_MOUNT, verified_runtime from sandboxer.models import Profile @@ -35,6 +36,7 @@ class BwrapExtension(SandboxExtension): def __init__(self, config: dict[str, Any] | None = None) -> None: super().__init__(config) + self.messages_route: OwnerMessagesRoute | None = None cfg = self.config self.base_dir: str = cfg.get("base_dir", "/tmp/sandboxer-bwrap") self.bwrap_bin: str = cfg.get("bwrap_bin", "bwrap") @@ -75,9 +77,16 @@ class BwrapExtension(SandboxExtension): runner = Path(__file__).with_name("bwrap_runner.py") argv += ["--dir", "/run", "--dir", "/run/sandboxer"] argv += ["--ro-bind", str(runner), "/run/sandboxer/bwrap_runner.py"] - if egress_socket: + if self.messages_route is not None: + if egress_socket: + raise ValueError("metered route cannot coexist with CONNECT egress") + argv += [ + "--ro-bind", str(self.messages_route.socket_path), "/run/sandboxer/messages.sock" + ] + if egress_socket or self.messages_route is not None: egress_module = Path(__file__).with_name("egress.py") argv += ["--ro-bind", str(egress_module), "/run/sandboxer/egress.py"] + if egress_socket: argv += ["--ro-bind", egress_socket, "/run/sandboxer/egress.sock"] runtime = verified_runtime(self.config) if runtime is not None: @@ -98,6 +107,8 @@ class BwrapExtension(SandboxExtension): argv.append("--runtime") if egress_socket: argv.append("--egress") + if self.messages_route is not None: + argv.append("--messages") return argv @staticmethod @@ -211,7 +222,7 @@ class BwrapExtension(SandboxExtension): try: return self._wait_ready(handle) except Exception: - if handle.get("egress_pid"): + if handle.get("egress_pid") or self.messages_route is not None: self.teardown(handle) raise @@ -276,6 +287,10 @@ class BwrapExtension(SandboxExtension): "max_output_bytes": max_output_bytes, "stdin_text": stdin_text, } + if self.messages_route is not None: + if credential_route_refs: + raise ValueError("metered route refuses provider credentials") + request["messages_token"] = self.messages_route.token if credential_route_refs: from sandboxer.extensions.credential_delivery import execute diff --git a/src/sandboxer/extensions/bwrap_runner.py b/src/sandboxer/extensions/bwrap_runner.py index 999c9f8..754cf5c 100644 --- a/src/sandboxer/extensions/bwrap_runner.py +++ b/src/sandboxer/extensions/bwrap_runner.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import os +import re import signal import socket import subprocess @@ -24,7 +25,8 @@ def _bounded_output(value: bytes | None, limit: int) -> tuple[str, bool]: def _run( - payload: dict, workspace: Path, *, runtime_enabled: bool = False, proxy_port: int | None = None + payload: dict, workspace: Path, *, runtime_enabled: bool = False, proxy_port: int | None = None, + messages_port: int | None = None, ) -> dict[str, object]: command = payload["command"] timeout_seconds = int(payload["timeout_seconds"]) @@ -63,6 +65,16 @@ def _run( ) ): raise ValueError("invalid owner credential envelope") + messages_token = payload.get("messages_token") + if messages_port is not None: + if (proxy_port is not None or credential_env or credential_refs + or not isinstance(messages_token, str) + or not re.fullmatch(r"[A-Za-z0-9_-]{43,100}", messages_token)): + raise ValueError("invalid metered owner envelope") + child_env["ANTHROPIC_BASE_URL"] = f"http://127.0.0.1:{messages_port}" + credential_env = {"ANTHROPIC_API_KEY": messages_token} + elif messages_token is not None: + raise ValueError("metered owner route unavailable") child_env.update(credential_env) timed_out = False process = subprocess.Popen( @@ -103,13 +115,20 @@ def main() -> int: workspace = Path(sys.argv[1]).resolve(strict=True) socket_path = Path(sys.argv[2]) runtime_enabled = "--runtime" in sys.argv[3:] - if any(flag not in {"--runtime", "--egress"} for flag in sys.argv[3:]): + if any(flag not in {"--runtime", "--egress", "--messages"} for flag in sys.argv[3:]): raise ValueError("invalid owner runtime mode") proxy_port = None if "--egress" in sys.argv[3:]: from egress import bridge proxy_port = bridge("/run/sandboxer/egress.sock") + messages_port = None + if "--messages" in sys.argv[3:]: + if proxy_port is not None: + raise ValueError("metered route cannot coexist with CONNECT egress") + from egress import bridge + + messages_port = bridge("/run/sandboxer/messages.sock") state = Path("/run/sandboxer/state") state.mkdir(mode=0o700) for name in ("home", "config", "cache", "data", "tmp"): @@ -141,6 +160,7 @@ def main() -> int: workspace, runtime_enabled=runtime_enabled, proxy_port=proxy_port, + messages_port=messages_port, ) except Exception as exc: response = {"boundary_error": str(exc)} diff --git a/src/sandboxer/extensions/messages_route.py b/src/sandboxer/extensions/messages_route.py new file mode 100644 index 0000000..c26bb72 --- /dev/null +++ b/src/sandboxer/extensions/messages_route.py @@ -0,0 +1,65 @@ +"""Ephemeral trusted-owner binding; never a profile, API or stored secret.""" + +from __future__ import annotations + +import os +import re +import stat +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass(frozen=True) +class OwnerMessagesRoute: + socket_path: Path = field(repr=False) + token: str = field(repr=False) + profile_id: str + actor: str + project: str + run_id: str + private_paths: tuple[Path, ...] = field(default=(), repr=False) + + def validate(self, profile, consumer, backend, inputs) -> None: + from sandboxer.extensions.bwrap import BwrapExtension + + if type(backend) is not BwrapExtension: + raise ValueError("metered route requires the bwrap owner") + if ( + profile.id != self.profile_id + or consumer.actor.value != self.actor + or consumer.project != self.project + or consumer.run_id != self.run_id + or not self.run_id + ): + raise ValueError("metered route consumer mismatch") + if profile.setup.secret_refs: + raise ValueError("metered route refuses setup credential acquisition") + if profile.network.default != "deny" or profile.network.egress: + raise ValueError("metered route requires empty default-deny egress") + if backend.config.get("allowed_egress") or backend.config.get("credential_routes"): + raise ValueError("metered route cannot coexist with provider egress or credentials") + if not re.fullmatch(r"[A-Za-z0-9_-]{43,100}", self.token): + raise ValueError("invalid metered route token") + path = self.socket_path + metadata = path.lstat() + parent = path.parent.lstat() + if ( + not path.is_absolute() or path.resolve() != path + or not stat.S_ISSOCK(metadata.st_mode) or metadata.st_uid != os.getuid() + or stat.S_IMODE(metadata.st_mode) != 0o600 + or parent.st_uid != os.getuid() or stat.S_IMODE(parent.st_mode) != 0o700 + ): + raise ValueError("metered route requires a private owner socket") + safe_binds = {"/usr", "/bin", "/lib", "/lib64", "/etc/resolv.conf", "/etc/ssl/certs"} + if not set(backend.ro_binds).issubset(safe_binds): + raise ValueError("metered route refuses additional host mounts") + exposed = [Path(backend.base_dir), *map(Path, backend.ro_binds)] + if inputs.get("repo"): + exposed.append(Path(inputs["repo"])) + if backend.config.get("runtime"): + exposed.append(Path(backend.config["runtime"]["path"])) + for private in (path.parent, *self.private_paths): + for visible in exposed: + a, b = private.resolve(), visible.resolve() + if a.is_relative_to(b) or b.is_relative_to(a): + raise ValueError("metered owner state overlaps a workload mount") diff --git a/tests/test_messages_route.py b/tests/test_messages_route.py new file mode 100644 index 0000000..efeb25c --- /dev/null +++ b/tests/test_messages_route.py @@ -0,0 +1,64 @@ +"""Trusted binding refuses authority expansion before provisioning.""" + +import socket +from dataclasses import replace +from types import SimpleNamespace + +import pytest + +from sandboxer.extensions.bwrap import BwrapExtension +from sandboxer.extensions.messages_route import OwnerMessagesRoute +from sandboxer.models import Consumer +from sandboxer.profiles.loader import load_profile + + +@pytest.fixture +def binding(tmp_path): + private = tmp_path / "private" + private.mkdir(mode=0o700) + path = private / "route.sock" + with socket.socket(socket.AF_UNIX) as listener: + listener.bind(str(path)) + path.chmod(0o600) + yield OwnerMessagesRoute(path, "a" * 43, "profile.bwrap-local", "agt", "fixture", "run-1") + + +def test_exact_route_has_only_fixed_socket_mount(binding, tmp_path): + backend = BwrapExtension({"base_dir": str(tmp_path / "workspaces")}) + profile = load_profile("profile.bwrap-local") + consumer = Consumer(actor="agt", project="fixture", run_id="run-1") + binding.validate(profile, consumer, backend, {}) + backend.messages_route = binding + argv = backend._bwrap_argv(str(tmp_path / "workspaces" / "one")) + assert "--unshare-net" in argv and "--messages" in argv + assert str(binding.socket_path) in argv and binding.token not in argv + assert "--egress" not in argv + assert binding.token not in repr(binding) + + +@pytest.mark.parametrize("change", ["actor", "project", "run_id", "profile", "network", + "allowlist", "credentials", "mount", "private", "backend"]) +def test_route_refuses_expansion(binding, tmp_path, change): + backend = BwrapExtension({"base_dir": str(tmp_path / "workspaces")}) + profile = load_profile("profile.bwrap-local") + consumer = Consumer(actor="agt", project="fixture", run_id="run-1") + if change in {"actor", "project", "run_id"}: + consumer = consumer.model_copy(update={change: "adm" if change == "actor" else "other"}) + if change == "actor": + consumer = Consumer(actor="adm", project="fixture", run_id="run-1") + elif change == "profile": + binding = replace(binding, profile_id="profile.other") + elif change == "network": + profile.network.egress = ["api.anthropic.com:443"] + elif change == "allowlist": + backend.config["allowed_egress"] = ["api.anthropic.com:443"] + elif change == "credentials": + backend.config["credential_routes"] = {"route": {}} + elif change == "mount": + backend.ro_binds.append("/home") + elif change == "private": + backend.base_dir = str(binding.socket_path.parent) + elif change == "backend": + backend = SimpleNamespace() + with pytest.raises(ValueError): + binding.validate(profile, consumer, backend, {}) diff --git a/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md b/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md index 78de215..366a364 100644 --- a/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md +++ b/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md @@ -8,7 +8,7 @@ status: blocked owner: codex topic_slug: bwrap-runtime-and-private-state created: "2026-09-05" -updated: "2026-09-08" +updated: "2026-09-09" state_hub_workstream_id: "d3f12387-fd23-58f0-b979-9c811507614d" --- @@ -248,3 +248,15 @@ full check used that canonical basename. Evidence: `docs/evidence/SAND-WP-0015-protected-local-install-2026-09-08.json`. T04 retains owner execution configuration, native credential/egress and real-model acceptance; Railiance installation needs its own target-specific return. + +## 2026-09-09 factory metered route source return + +Implemented the trusted, ephemeral Messages route described in +[docs/bwrap-messages-route.md](../docs/bwrap-messages-route.md). Actual local +bwrap owner transport proves provider key/ledger separation, direct-route denial, +revocation and teardown; rein also proves metered request plus commit import and +close replay. `make check`: lint clean, 199 passed. T04 remains waiting for the +admitted provider-to-owner bootstrap, updated protected runtime/profile, Railiance +placement, live compatibility and G0. Existing child-provider-key/direct-CONNECT +proofs do not admit this different credential holder or metered profile. No CCR, +secret read, deployment or paid request was performed.