Compare commits

...

2 commits

Author SHA1 Message Date
d477c3b5d9 feat: enforce owner allowlisted bwrap HTTPS egress
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0726e-5232-73f2-aaca-2c05ceb62efb
2026-09-05 22:08:18 +02:00
d69827aaa2 feat: pin bwrap rein runtimes and isolate private state
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0726e-5232-73f2-aaca-2c05ceb62efb
2026-09-05 20:36:11 +02:00
18 changed files with 1192 additions and 31 deletions

View file

@ -22,6 +22,7 @@
| 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 |
| workplan | SAND-WP-0015 | blocked | — | workplans/SAND-WP-0015-bwrap-runtime-and-private-state.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 |
@ -117,3 +118,7 @@
| 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 |
| task | SAND-WP-0015-T01 | done | — | workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md |
| task | SAND-WP-0015-T02 | done | — | workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md |
| task | SAND-WP-0015-T03 | done | — | workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md |
| task | SAND-WP-0015-T04 | wait | — | workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md |

39
docs/bwrap-egress.md Normal file
View file

@ -0,0 +1,39 @@
# Bwrap HTTPS egress
Owner extension config can set `allowed_egress: [api.anthropic.com:443]`.
A profile must declare a subset in `network.egress`, with `default: deny`.
Defaults remain empty. Only exact lowercase DNS names ending in `:443`
are accepted; no wildcards, IP literals, URLs or other ports.
Each opted-in sandbox gets a separate host-side Unix CONNECT proxy. Only that
socket is mounted into the sandbox; an in-namespace loopback bridge provides
HTTPS_PROXY/https_proxy to child processes. The namespace retains only lo,
without a host interface or direct DNS/network access. The owner proxy resolves
the declared hostname, rejects any non-global result, and connects to a checked
numeric address without a second lookup. TLS stays between client and provider;
system CA certificates are mounted read-only. No TLS interception or body logging.
This enforces connection destinations, not HTTP paths, provider account identity,
or TLS SNI on shared hosting. A client can send arbitrary bytes to an allowed
server. It is not an application firewall or a defense against an allowed
provider's own forwarding features. A future stronger policy needs separate
application enforcement. DNS resolution uses the trusted host resolver.
Up to 16 concurrent tunnels per sandbox, 8 KiB CONNECT headers, 10-second
socket operations, 30-second idle and 900-second tunnel lifetime. Unsupported
methods, hosts, ports and request framing close without upstream dialing.
Teardown kills the dedicated proxy process and removes its socket directory.
The current host owner must remain trusted; no caller-selected proxy config.
Validation: `scripts/smoke-bwrap-egress.py` made a credential-free TLS GET to
api.anthropic.com from sandbox e290e788 and received HTTP 404. Undeclared
example.com and direct 1.1.1.1:443 were denied, only lo existed, and proxy plus
workspace teardown passed. This is transport evidence, not model authentication.
Unit tests cover invalid destinations, private and mapped-loopback DNS,
checked-address dialing, CONNECT framing and owner/profile allowlist separation.
Claude documents HTTPS_PROXY at https://code.claude.com/docs/en/network-config.
The pinned Claude executable must still pass its own proxy/startup compatibility
proof. The existing production profiles are unchanged. CCR-2026-0016 custody is
complete at version 2, but owner machine authentication and protected credential
delivery remain outstanding under SAND-WP-0015-T04 and GLAS-WP-0012-T02.

124
docs/bwrap-runtime.md Normal file
View file

@ -0,0 +1,124 @@
# Bwrap runtime and private state
`ext.bwrap` can mount a standalone Python runtime selected by the owner's
extension configuration. A create/exec caller cannot select host paths or
replace the sanitized child environment.
```yaml
config:
runtime:
path: /srv/sandboxer/runtimes/rein-aharness-example
sha256: <reviewed-complete-artifact-digest>
```
The path must be a canonical absolute directory with `pyvenv.cfg` and
`bin/python3`. Its complete tree digest includes file contents, modes, directory
entries, and internal symlinks. Absolute/escaping symlinks and special files
are refused. Missing files, added files, changed permissions, or changed
content invalidate the pin. The runtime must not overlap the source checkout
or writable sandbox workspace.
The owner mounts the artifact read-only at `/opt/sandboxer/runtime` and prepends
its `bin` directory to the child PATH. Console entrypoints must use that fixed
mount prefix. Artifacts remain an owner trust boundary: publish them in a
protected location and do not mutate them while sandboxes use them. Digest
validation occurs at creation; it is not a mechanism for making a mutable host
directory immutable to its owner.
## Building and testing the Python rein bundle
From this repository, with committed rein/llm-connect source checkouts:
```bash
uv run python scripts/build-rein-runtime.py \
--output /tmp/rein-runtime-candidate \
--rein-source ../rein-aharness \
--llm-source ../llm-connect
```
The builder creates a standalone system-Python venv, installs both packages
non-editably with their dependencies, rewrites Python console entrypoint
shebangs to the fixed mount path, and records source revisions plus resolved
package versions in `build-info.json`. It prints the resulting path/digest.
Dependency versions are resolved during this candidate build; the digest pins
the produced artifact, not a promise that another build will be bit-identical.
No interactive home, credentials, or provider login is copied into the bundle.
Prove the artifact using the returned values:
```bash
uv run python scripts/smoke-bwrap-runtime.py \
--runtime-path /tmp/rein-runtime-candidate \
--runtime-sha256 <returned-digest>
```
The smoke uses an isolated owner extension configuration and the real broker.
It imports the rein's Claude adapter and llm-connect, runs `rein-aharness
--help`, proves the runtime rejects writes, writes private state outside the
worktree, verifies source absence and a clean Git tree, checks loopback-only
networking, and tears down. This is real CLI startup, not a model task.
The Python bundle does not package or pin the separate Claude executable.
That executable's release, startup behavior, and authentication still require
review in the eventual production runtime. No committed profile selects a
temporary `/tmp` build as its production runtime.
## Private writable state
The broker creates mode-0700 directories in the namespace's temporary root:
| Environment variable | Location |
|---|---|
| `HOME` | `/run/sandboxer/state/home` |
| `XDG_CONFIG_HOME` | `/run/sandboxer/state/config` |
| `XDG_CACHE_HOME` | `/run/sandboxer/state/cache` |
| `XDG_STATE_HOME` | `/run/sandboxer/state/data` |
| `TMPDIR` | `/run/sandboxer/state/tmp` |
These directories persist between exec requests in the same sandbox, remain
outside the Git worktree, and disappear with namespace teardown. Python user
site packages and bytecode writes are disabled. The owner's child environment
continues to carry exact consumer identity and value-free route references.
## Network and credentials remain separate gates
Bwrap refuses `network.default: allow` and setup secret references. Network
egress is opt-in through the owner allowlist described in bwrap-egress.md;
empty-egress profiles retain loopback-only networking. There is still no
credential-delivery mechanism. Returning a
declared egress list as evidence would not make that list enforced or usable.
Exec credential route references continue to be labels, not credential values
or delivery grants.
On 2026-09-05, `warden route find anthropic --json` and `warden route find
claude-code --json` returned no matching workload routes. The generic OpenBao
entry is explicitly a routing template, not an executable credential lane.
The OpenRouter AppRole route belongs to the other rein and is not a substitute
for Claude authentication.
`SAND-WP-0015-T04` / `SAND-WP-0014-T05` and `GLAS-WP-0012-T02` retain the
production requirements: a concrete owner-approved Claude credential route,
delivery and revocation semantics, a pinned Claude runtime, and explicitly
enforced provider egress with negative tests. Glas local profiles remain
blocked until those requirements and the real-model acceptance pass.
## 2026-09-05 candidate evidence
Runtime SHA-256:
`4c316737ec2715936a12c4f49621a5e4be3d1f4fe4739130393f28cdda66fbd9`.
Candidate path: `/tmp/sandboxer-rein-aharness-runtime-20260905-v2`.
Source revisions: rein-aharness `1429db5ad4c83331b6375349ffde1eb13af9575b`,
llm-connect `00560945f81ba6ff1f5cacd9fe99c7fe756cc4b1`; Python 3.12.3.
Sandbox `f333fb66` passed the real CLI/import/read-only/private-state smoke.
`sys.prefix` was `/opt/sandboxer/runtime`, HOME was outside the worktree with
mode 0700, Git stayed clean, source was absent, only `lo` existed, credential
references were empty, and the workspace was removed. No model request or
credential acquisition occurred.
Final validation: `make check` passed lint and 132 tests. Authenticated owner
API smoke `223db65b` returned HTTP 200 with exact identity/stdin and complete
teardown. Follow-up runtime smoke `d4de9531` repeated the real CLI/startup
checks and proved private state survives a second exec in the same namespace.
The default unconfigured `profile.bwrap-local` still has no selected rein
bundle; candidate startup does not constitute production deployment.

View file

@ -0,0 +1,67 @@
"""Build a standalone rein-aharness runtime for the fixed bwrap mount path.
Only package installation occurs here. No credentials or model calls are used.
Run with the sand-boxer Python environment; uv must be available on PATH.
"""
from __future__ import annotations
import argparse
import json
import subprocess
from pathlib import Path
from sandboxer.extensions.runtime import RUNTIME_MOUNT, runtime_digest
def checked(command: list[str]) -> str:
result = subprocess.run(command, capture_output=True, text=True, timeout=300)
if result.returncode:
raise RuntimeError(f"runtime build command failed: {Path(command[0]).name}")
return result.stdout.strip()
def build(output: Path, rein_source: Path, llm_source: Path) -> dict:
revisions = {}
for name, source in (("rein-aharness", rein_source), ("llm-connect", llm_source)):
if checked(["git", "-C", str(source), "status", "--porcelain"]):
raise ValueError(f"{name} source must be committed before building")
revisions[name] = checked(["git", "-C", str(source), "rev-parse", "HEAD"])
output.mkdir(parents=True, exist_ok=False)
checked(["/usr/bin/python3", "-m", "venv", "--copies", "--without-pip", str(output)])
checked(["uv", "pip", "install", "--python", str(output / "bin/python3"),
str(rein_source), str(llm_source)])
# Console entrypoints must reference the in-sandbox mount, not the build host.
for path in (output / "bin").iterdir():
if not path.is_file() or path.is_symlink():
continue
with path.open("rb") as stream:
first_line = stream.readline(4096)
if first_line.startswith(f"#!{output}/bin/python".encode()):
body = path.read_bytes().partition(b"\n")[2]
path.write_bytes(f"#!{RUNTIME_MOUNT}/bin/python3\n".encode() + body)
metadata = json.loads(checked([
str(output / "bin/python3"), "-c",
"import importlib.metadata,json,platform; "
"print(json.dumps({'python':platform.python_version(),'packages':"
"{d.metadata['Name']:d.version for d in importlib.metadata.distributions()}}))",
]))
metadata["source_revisions"] = revisions
(output / "build-info.json").write_text(json.dumps(metadata, indent=2) + "\n")
return {"runtime": {"path": str(output), "sha256": runtime_digest(output)},
"build": metadata}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--rein-source", type=Path, required=True)
parser.add_argument("--llm-source", type=Path, required=True)
args = parser.parse_args()
result = build(args.output.resolve(), args.rein_source.resolve(), args.llm_source.resolve())
print(json.dumps(result, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,70 @@
import importlib.util
import json
import os
import tempfile
from pathlib import Path
from unittest.mock import patch
from sandboxer.core.manager import SandboxManager
from sandboxer.extensions.bwrap import BwrapExtension
from sandboxer.lifecycle.store import SandboxStore
from sandboxer.models import Extension, Profile, SandboxCreateRequest, SandboxExecRequest
spec = importlib.util.spec_from_file_location(
"proof", Path(__file__).with_name("smoke-bwrap-egress.py")
)
proof = importlib.util.module_from_spec(spec)
spec.loader.exec_module(proof)
os.environ["SANDBOXER_NO_STATE_HUB"] = "1"
with tempfile.TemporaryDirectory(prefix="glas-managed-egress-") as directory:
root = Path(directory)
profile = Profile(
id="profile.egress-proof",
version="1",
extension="ext.bwrap",
network={"default": "deny", "egress": ["api.anthropic.com:443"]},
)
ext = Extension(
id="ext.bwrap",
title="proof",
handler="sandboxer.extensions.bwrap:BwrapExtension",
config={"base_dir": str(root / "sandboxes"), "allowed_egress": ["api.anthropic.com:443"]},
)
consumer = {"actor": "agt", "project": "glas-harness", "run_id": "egress-proof"}
with (
patch("sandboxer.core.manager.load_profile", return_value=profile),
patch("sandboxer.core.manager.resolve_extension", return_value=ext),
patch("sandboxer.core.manager.load_extension", return_value=ext),
):
store = SandboxStore(path=root / "sandboxes.json")
mgr = SandboxManager(store=store)
created = mgr.create(
SandboxCreateRequest(profile=profile.id, consumer=consumer), host="localhost"
)
try:
# Different manager and store instances reconstruct the persisted owner handle.
resumed = SandboxManager(store=SandboxStore(path=root / "sandboxes.json"))
result = resumed.execute(
created.sandbox_id,
SandboxExecRequest(
command=["python3", "-c", proof.PROBE], consumer=consumer, timeout_seconds=40
),
)
assert result.exit_code == 0, result.stderr
finally:
SandboxManager(store=SandboxStore(path=root / "sandboxes.json")).destroy(
created.sandbox_id
)
assert not Path(created.inputs["egress_dir"]).exists()
assert not BwrapExtension._pid_alive(int(created.inputs["egress_pid"]))
print(
json.dumps(
{
"sandbox_id": created.sandbox_id,
"manager_restart": True,
"proof": json.loads(result.stdout),
"proxy_removed": True,
"model_call": False,
}
)
)

View file

@ -0,0 +1,89 @@
"""Non-secret HTTPS reachability and denied egress proof."""
import json
import tempfile
from pathlib import Path
from sandboxer.extensions.bwrap import BwrapExtension
from sandboxer.models import Profile
PROBE = r"""
import json, os, socket, ssl, urllib.request, urllib.error
from pathlib import Path
proxy = os.environ['HTTPS_PROXY']
# GET without any credential: TLS and a provider HTTP response suffice.
client = urllib.request.build_opener(urllib.request.ProxyHandler({'https': proxy}))
try:
response = client.open('https://api.anthropic.com/', timeout=20)
status = response.status
response.close()
except urllib.error.HTTPError as error:
status = error.code
assert 100 <= status <= 599
try:
client.open('https://example.com/', timeout=5)
except (urllib.error.URLError, OSError):
denied = True
else:
raise AssertionError('undeclared destination reachable')
try:
socket.create_connection(('1.1.1.1',443),timeout=2)
except OSError:
direct_denied = True
else:
raise AssertionError('direct network reachable')
interfaces = Path('/proc/net/dev').read_text().splitlines()[2:]
assert [line.split(':')[0].strip() for line in interfaces] == ['lo']
print(json.dumps({'provider_http_status':status, 'tls_verified':True,
'undeclared_host_denied':denied, 'direct_network_denied':direct_denied}))
"""
def main():
with tempfile.TemporaryDirectory(prefix="sandboxer-egress-proof-") as directory:
extension = BwrapExtension(
{
"base_dir": directory,
"allowed_egress": ["api.anthropic.com:443"],
"ro_binds": ["/usr", "/bin", "/lib", "/lib64", "/etc/ssl/certs"],
}
)
profile = Profile(
id="profile.egress-proof",
version="1",
extension="ext.bwrap",
network={"default": "deny", "egress": ["api.anthropic.com:443"]},
)
handle = extension.provision(profile, {}, "localhost")
try:
extension.wait_ready(handle)
result = extension.execute(
handle,
["python3", "-c", PROBE],
credential_route_refs=[],
execution_context={},
timeout_seconds=40,
max_output_bytes=4096,
)
finally:
teardown = extension.teardown(handle)
assert result["exit_code"] == 0, result
assert teardown["workspace_removed"] == "True"
assert not Path(handle["egress_dir"]).exists()
assert not extension._pid_alive(int(handle["egress_pid"]))
print(
json.dumps(
{
"sandbox_id": handle["sandbox_id"],
"proof": json.loads(result["stdout"]),
"proxy_removed": True,
"workspace_removed": True,
"model_call": False,
},
indent=2,
)
)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,105 @@
"""Prove a real rein CLI starts in a pinned, read-only owner runtime.
No credential acquisition or model request is performed.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import tempfile
from pathlib import Path
from sandboxer.extensions.bwrap import BwrapExtension
from sandboxer.models import Profile
PROBE = r'''
import json, os, subprocess, sys
from pathlib import Path
import rein_aharness.adapter
import llm_connect
source = Path(sys.argv[1])
runtime = Path('/opt/sandboxer/runtime')
home = Path(os.environ['HOME'])
private = home / 'private-state-proof'
private.write_text('non-secret private state\n')
assert not home.is_relative_to(Path.cwd())
assert (home.stat().st_mode & 0o777) == 0o700
assert private.is_file()
try:
(runtime / 'write-probe').write_text('must be refused')
except OSError:
readonly = True
else:
readonly = False
assert readonly
assert not source.exists()
help_result = subprocess.run(['rein-aharness', '--help'], capture_output=True, timeout=15)
assert help_result.returncode == 0
assert b'usage:' in help_result.stdout
assert subprocess.check_output(['git', 'status', '--porcelain', '--ignored=matching']) == b''
interfaces = [line.split(':', 1)[0].strip()
for line in Path('/proc/net/dev').read_text().splitlines()[2:]]
assert interfaces == ['lo']
print(json.dumps({'rein_cli_started': True, 'adapter_imported': True,
'runtime_readonly': readonly, 'source_absent': True,
'home_outside_workspace': True, 'home_mode': '0700',
'worktree_clean': True, 'interfaces': interfaces,
'python_prefix': sys.prefix,
'credential_refs': json.loads(os.environ['SANDBOXER_CREDENTIAL_ROUTE_REFS'])}))
'''
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--runtime-path", required=True)
parser.add_argument("--runtime-sha256", required=True)
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix="sandboxer-runtime-proof-") as temporary:
root = Path(temporary)
source = root / "source"
source.mkdir()
subprocess.run(["git", "init", "-q", str(source)], check=True)
extension = BwrapExtension({
"base_dir": str(root / "sandboxes"),
"runtime": {"path": args.runtime_path, "sha256": args.runtime_sha256},
})
profile = Profile(id="profile.runtime-proof", version="1", extension="ext.bwrap")
handle = extension.provision(profile, {"repo": str(source)}, "localhost")
try:
extension.wait_ready(handle)
result = extension.execute(
handle, ["python3", "-c", PROBE, str(source)],
credential_route_refs=[],
execution_context={"actor": "agt", "project": "sand-boxer-runtime-proof",
"run_id": "sand-wp-0015-proof"},
timeout_seconds=30, max_output_bytes=65536,
)
persistence = extension.execute(
handle, ["python3", "-c",
"import os; from pathlib import Path; "
"assert (Path(os.environ['HOME']) / 'private-state-proof').is_file()"],
credential_route_refs=[],
execution_context={"actor": "agt", "project": "sand-boxer-runtime-proof",
"run_id": "sand-wp-0015-proof"},
timeout_seconds=15, max_output_bytes=1024,
)
finally:
teardown = extension.teardown(handle)
passed = result["exit_code"] == 0 and not result["timed_out"]
facts = json.loads(result["stdout"]) if passed else {}
private_state_persisted = persistence["exit_code"] == 0
passed = passed and private_state_persisted and teardown["workspace_removed"] == "True"
print(json.dumps({
"ok": passed, "sandbox_id": handle["sandbox_id"],
"runtime_sha256": args.runtime_sha256, "proof": facts,
"workspace_removed": teardown["workspace_removed"] == "True",
"exit_code": result["exit_code"], "model_run_proven": False,
"private_state_persisted": private_state_persisted,
}, indent=2))
return 0 if passed else 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -75,6 +75,8 @@ class SandboxManager:
"provider_sandbox_id": status.inputs.get("provider_sandbox_id", ""),
"provider": status.inputs.get("provider", ""),
"pid": status.inputs.get("pid", ""),
"egress_pid": status.inputs.get("egress_pid", ""),
"egress_dir": status.inputs.get("egress_dir", ""),
"workspace_dir": status.inputs.get("workspace_dir", ""),
}
@ -166,6 +168,8 @@ class SandboxManager:
status.inputs["provider_sandbox_id"] = handle.get("provider_sandbox_id", "")
status.inputs["provider"] = handle.get("provider", "")
status.inputs["pid"] = handle.get("pid", "")
status.inputs["egress_pid"] = handle.get("egress_pid", "")
status.inputs["egress_dir"] = handle.get("egress_dir", "")
status.inputs["workspace_dir"] = handle.get("workspace_dir", "")
reach = backend.wait_ready(handle)
reach = enrich_reachability(reach, profile, handle)

View file

@ -9,12 +9,15 @@ import shutil
import signal
import socket
import subprocess
import sys
import time
from contextlib import suppress
from pathlib import Path
from typing import Any
from sandboxer.extensions.base import SandboxExtension
from sandboxer.extensions.egress import destinations
from sandboxer.extensions.runtime import RUNTIME_MOUNT, verified_runtime
from sandboxer.models import Profile
@ -35,11 +38,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.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"]
"ro_binds", ["/usr", "/bin", "/lib", "/lib64", "/etc/resolv.conf", "/etc/ssl/certs"]
)
def _bwrap_bin(self) -> str:
@ -48,7 +49,9 @@ 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, *, info_fd: int | None = None) -> list[str]:
def _bwrap_argv(
self, workspace_dir: str, *, info_fd: int | None = None, egress_socket: str | None = None
) -> list[str]:
argv = [
self._bwrap_bin(),
"--unshare-user",
@ -72,6 +75,17 @@ 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:
egress_module = Path(__file__).with_name("egress.py")
argv += ["--ro-bind", str(egress_module), "/run/sandboxer/egress.py"]
argv += ["--ro-bind", egress_socket, "/run/sandboxer/egress.sock"]
runtime = verified_runtime(self.config)
if runtime is not None:
workspace = Path(workspace_dir).resolve()
if runtime.is_relative_to(workspace) or workspace.is_relative_to(runtime):
raise ValueError("runtime and workspace must not overlap")
argv += ["--dir", "/opt", "--dir", "/opt/sandboxer"]
argv += ["--ro-bind", str(runtime), RUNTIME_MOUNT]
argv += ["--bind", workspace_dir, workspace_dir]
argv += ["--chdir", workspace_dir]
argv += [
@ -80,6 +94,10 @@ class BwrapExtension(SandboxExtension):
workspace_dir,
f"{workspace_dir}/{self.control_socket_name}",
]
if runtime is not None:
argv.append("--runtime")
if egress_socket:
argv.append("--egress")
return argv
@staticmethod
@ -99,9 +117,20 @@ class BwrapExtension(SandboxExtension):
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]:
def provision(self, profile: Profile, inputs: dict[str, str], host: str) -> dict[str, str]:
if profile.network.default != "deny":
raise ValueError("bwrap requires default-deny networking")
requested = destinations(profile.network.egress)
allowed = destinations(self.config.get("allowed_egress", []))
if not requested.issubset(allowed):
raise ValueError("profile egress exceeds owner allowlist")
if profile.setup.secret_refs:
raise ValueError("bwrap has no setup credential delivery contract")
runtime = verified_runtime(self.config)
if runtime is not None and inputs.get("repo"):
source = Path(inputs["repo"]).resolve()
if source.is_relative_to(runtime) or runtime.is_relative_to(source):
raise ValueError("runtime and source checkout must not overlap")
sandbox_id = self.new_sandbox_id(inputs)
workspace_dir = f"{self.base_dir}/{sandbox_id}"
Path(workspace_dir).mkdir(parents=True, exist_ok=True)
@ -114,22 +143,60 @@ class BwrapExtension(SandboxExtension):
shutil.copytree(repo_path, workspace_dir, dirs_exist_ok=True)
Path(workspace_dir).chmod(0o700)
info_read_fd, info_write_fd = os.pipe()
try:
argv = self._bwrap_argv(workspace_dir, info_fd=info_write_fd)
proc = subprocess.Popen(
argv,
proxy = None
proxy_dir = None
egress_socket = None
if requested:
import tempfile
proxy_dir = tempfile.mkdtemp(prefix="sandboxer-egress-")
egress_socket = str(Path(proxy_dir) / "proxy.sock")
proxy = subprocess.Popen(
[
sys.executable,
str(Path(__file__).with_name("egress.py")),
egress_socket,
*sorted(requested),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
pass_fds=(info_write_fd,),
)
finally:
os.close(info_write_fd)
deadline = time.monotonic() + 5
while not Path(egress_socket).is_socket():
if proxy.poll() is not None or time.monotonic() >= deadline:
proxy.kill()
proxy.wait()
shutil.rmtree(proxy_dir)
raise RuntimeError("egress proxy failed to start")
time.sleep(0.02)
try:
child_pid = self._read_child_pid(proc, info_read_fd)
finally:
os.close(info_read_fd)
info_read_fd, info_write_fd = os.pipe()
try:
argv = self._bwrap_argv(
workspace_dir, info_fd=info_write_fd, egress_socket=egress_socket
)
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)
except BaseException:
if proxy:
proxy.kill()
proxy.wait()
if proxy_dir:
shutil.rmtree(proxy_dir, ignore_errors=True)
raise
return {
"sandbox_id": sandbox_id,
@ -137,9 +204,18 @@ class BwrapExtension(SandboxExtension):
"pid": str(child_pid),
"supervisor_pid": str(proc.pid),
"workspace_dir": workspace_dir,
**({"egress_pid": str(proxy.pid), "egress_dir": proxy_dir} if proxy else {}),
}
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
try:
return self._wait_ready(handle)
except Exception:
if handle.get("egress_pid"):
self.teardown(handle)
raise
def _wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
pid = int(handle["pid"])
if not self._pid_alive(pid):
raise RuntimeError(f"bwrap process {pid} is not running")
@ -233,6 +309,13 @@ class BwrapExtension(SandboxExtension):
os.kill(pid, signal.SIGKILL)
killed = True
if handle.get("egress_pid"):
with suppress(ProcessLookupError):
os.killpg(int(handle["egress_pid"]), signal.SIGKILL)
with suppress(ChildProcessError):
os.waitpid(int(handle["egress_pid"]), 0)
if handle.get("egress_dir"):
shutil.rmtree(handle["egress_dir"], ignore_errors=True)
workspace_dir = handle.get("workspace_dir", "")
removed = False
if workspace_dir and Path(workspace_dir).exists():

View file

@ -23,7 +23,9 @@ def _bounded_output(value: bytes | None, limit: int) -> tuple[str, bool]:
return raw.decode("utf-8", errors="replace"), truncated
def _run(payload: dict, workspace: Path) -> dict[str, object]:
def _run(
payload: dict, workspace: Path, *, runtime_enabled: bool = False, proxy_port: int | None = None
) -> dict[str, object]:
command = payload["command"]
timeout_seconds = int(payload["timeout_seconds"])
max_output_bytes = int(payload["max_output_bytes"])
@ -31,12 +33,23 @@ def _run(payload: dict, workspace: Path) -> dict[str, object]:
context = payload.get("execution_context", {})
stdin_text = payload.get("stdin_text")
child_env = {
"HOME": str(workspace),
"HOME": "/run/sandboxer/state/home",
"XDG_CONFIG_HOME": "/run/sandboxer/state/config",
"XDG_CACHE_HOME": "/run/sandboxer/state/cache",
"XDG_STATE_HOME": "/run/sandboxer/state/data",
"TMPDIR": "/run/sandboxer/state/tmp",
"PYTHONDONTWRITEBYTECODE": "1",
"PYTHONNOUSERSITE": "1",
"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()},
}
if runtime_enabled:
child_env["PATH"] = "/opt/sandboxer/runtime/bin:" + child_env["PATH"]
if proxy_port is not None:
child_env["HTTPS_PROXY"] = f"http://127.0.0.1:{proxy_port}"
child_env["https_proxy"] = child_env["HTTPS_PROXY"]
timed_out = False
process = subprocess.Popen(
command,
@ -49,9 +62,7 @@ def _run(payload: dict, workspace: Path) -> dict[str, object]:
)
try:
stdin_bytes = stdin_text.encode("utf-8") if stdin_text is not None else None
stdout_raw, stderr_raw = process.communicate(
input=stdin_bytes, timeout=timeout_seconds
)
stdout_raw, stderr_raw = process.communicate(input=stdin_bytes, timeout=timeout_seconds)
exit_code = process.returncode
except subprocess.TimeoutExpired:
timed_out = True
@ -73,6 +84,18 @@ def _run(payload: dict, workspace: Path) -> dict[str, object]:
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:]):
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")
state = Path("/run/sandboxer/state")
state.mkdir(mode=0o700)
for name in ("home", "config", "cache", "data", "tmp"):
(state / name).mkdir(mode=0o700)
socket_path.unlink(missing_ok=True)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
server.bind(str(socket_path))
@ -95,7 +118,12 @@ def main() -> int:
try:
if not chunks:
raise ValueError("empty or oversized execution request")
response = _run(json.loads(b"".join(chunks)), workspace)
response = _run(
json.loads(b"".join(chunks)),
workspace,
runtime_enabled=runtime_enabled,
proxy_port=proxy_port,
)
except Exception as exc:
response = {"boundary_error": str(exc)}
connection.sendall(json.dumps(response).encode("utf-8"))

View file

@ -0,0 +1,130 @@
"""Bounded HTTPS CONNECT egress; host-side destination enforcement."""
from __future__ import annotations
import ipaddress
import select
import socket
import threading
import time
from contextlib import suppress
from pathlib import Path
def destinations(entries: list[str]) -> frozenset[str]:
"""Only exact lowercase DNS names with an explicit TLS port."""
import re
if any(
not re.fullmatch(r"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}:443", x)
for x in entries
):
raise ValueError("egress requires exact lowercase DNS names with :443")
return frozenset(entries)
def connect_public(host: str) -> socket.socket:
addresses = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
if not addresses or any(not ipaddress.ip_address(a[4][0]).is_global for a in addresses):
raise ValueError("non-public destination")
# Connect to the already checked numeric address; never resolve a second time.
for family, kind, proto, _, address in addresses:
connection = socket.socket(family, kind, proto)
connection.settimeout(10)
try:
connection.connect(address)
return connection
except OSError:
connection.close()
raise OSError("destination unavailable")
def relay(left: socket.socket, right: socket.socket) -> None:
deadline = time.monotonic() + 900
for connection in (left, right):
connection.settimeout(10)
while time.monotonic() < deadline:
ready, _, _ = select.select([left, right], [], [], 30)
if not ready:
return
for source in ready:
chunk = source.recv(65536)
if not chunk:
return
(right if source is left else left).sendall(chunk)
def tunnel(client: socket.socket, allowed: frozenset[str]) -> None:
client.settimeout(10)
header = bytearray()
# Do not consume any TLS bytes following CONNECT headers.
while not header.endswith(b"\r\n\r\n"):
byte = client.recv(1)
if not byte or len(header) >= 8192:
raise ValueError("invalid CONNECT header")
header.extend(byte)
lines = header.decode("ascii").split("\r\n")
request = lines[0].split(" ")
if len(request) != 3 or request[0] != "CONNECT" or request[2] != "HTTP/1.1":
raise ValueError("CONNECT required")
target = request[1]
if target not in allowed:
raise ValueError("destination denied")
# No request body or transfer framing on CONNECT.
for line in lines[1:-2]:
name, separator, value = line.partition(":")
if not separator or name.lower() not in {"host", "proxy-connection", "user-agent"}:
raise ValueError("unsupported CONNECT header")
if name.lower() == "host" and value.strip() != target:
raise ValueError("inconsistent host")
with connect_public(target[:-4]) as upstream:
client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
relay(client, upstream)
def serve(listener: socket.socket, handler) -> None:
slots = threading.BoundedSemaphore(16)
def worker(client):
try:
with client, suppress(OSError, ValueError, UnicodeError):
handler(client)
finally:
slots.release()
listener.listen(16)
while True:
client, _ = listener.accept()
if not slots.acquire(blocking=False):
client.close()
continue
threading.Thread(target=worker, args=(client,), daemon=True).start()
def bridge(unix_path: str) -> int:
listener = socket.socket()
listener.bind(("127.0.0.1", 0))
port = listener.getsockname()[1]
def forward(client):
with socket.socket(socket.AF_UNIX) as upstream:
upstream.connect(unix_path)
relay(client, upstream)
threading.Thread(target=serve, args=(listener, forward), daemon=True).start()
return port
def main() -> None:
import sys
path = Path(sys.argv[1])
allowed = destinations(sys.argv[2:])
with socket.socket(socket.AF_UNIX) as listener:
listener.bind(str(path))
path.chmod(0o600)
serve(listener, lambda client: tunnel(client, allowed))
if __name__ == "__main__":
main()

View file

@ -0,0 +1,52 @@
"""Digest-pinned, owner-configured Python runtimes for bwrap."""
from __future__ import annotations
import hashlib
import json
import os
import stat
from pathlib import Path
RUNTIME_MOUNT = "/opt/sandboxer/runtime"
def runtime_digest(root: Path) -> str:
"""Hash the complete artifact, including modes and internal symlink targets."""
if not root.is_absolute() or root.resolve(strict=True) != root or not root.is_dir():
raise ValueError("runtime path must be a canonical absolute directory")
records = []
for path in sorted(root.rglob("*")):
mode = path.lstat().st_mode
if stat.S_ISLNK(mode):
target = os.readlink(path)
if Path(target).is_absolute() or not path.resolve(strict=True).is_relative_to(root):
raise ValueError("runtime symlink escapes the artifact")
value = ["symlink", target]
elif stat.S_ISREG(mode):
with path.open("rb") as stream:
value = ["file", hashlib.file_digest(stream, "sha256").hexdigest()]
elif stat.S_ISDIR(mode):
value = ["directory"]
else:
raise ValueError("runtime contains an unsupported special file")
records.append([path.relative_to(root).as_posix(), stat.S_IMODE(mode), *value])
return hashlib.sha256(json.dumps(records, separators=(",", ":")).encode()).hexdigest()
def verified_runtime(config: dict) -> Path | None:
"""Only owner extension configuration can select a runtime artifact."""
runtime = config.get("runtime")
if runtime is None:
return None
if not isinstance(runtime, dict) or set(runtime) != {"path", "sha256"}:
raise ValueError("runtime requires exactly path and sha256")
if not isinstance(runtime["path"], str) or not isinstance(runtime["sha256"], str):
raise ValueError("runtime path and sha256 must be strings")
root = Path(runtime["path"])
if (not (root / "pyvenv.cfg").is_file() or not (root / "bin/python3").is_file()
or (root / ".git").exists()):
raise ValueError("runtime must be a standalone Python environment, not a checkout")
if runtime_digest(root) != runtime["sha256"]:
raise ValueError("runtime artifact digest does not match owner configuration")
return root

View file

@ -188,8 +188,8 @@ class Reachability(BaseModel):
tunnel_via: str | None = None
identity: str | None = None
# Local (no-SSH-hop) descriptor — populated for same-host extensions
# like ext.bwrap. A consumer execs into the sandbox directly
# (e.g. `nsenter --target <pid> ...`) rather than over SSH.
# like ext.bwrap. Commands enter through owner-mediated execute;
# the pid is lifecycle metadata, not consumer setns authority.
pid: str | None = None
workspace_dir: str | None = None

View file

@ -298,12 +298,20 @@ def test_in_namespace_runner_uses_sanitized_environment(tmp_path) -> None:
]
assert set(child_env) == {
"HOME",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"XDG_STATE_HOME",
"TMPDIR",
"PYTHONDONTWRITEBYTECODE",
"PYTHONNOUSERSITE",
"LANG",
"PATH",
"SANDBOXER_CREDENTIAL_ROUTE_REFS",
"SANDBOXER_ACTOR",
"SANDBOXER_RUN_ID",
}
assert child_env["HOME"] == "/run/sandboxer/state/home"
assert child_env["HOME"] != str(tmp_path)
assert result["stdout"] == "ok\n"

116
tests/test_bwrap_runtime.py Normal file
View file

@ -0,0 +1,116 @@
import socket
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from sandboxer.extensions.bwrap import BwrapExtension
from sandboxer.extensions.bwrap_runner import _run
from sandboxer.extensions.runtime import RUNTIME_MOUNT, runtime_digest, verified_runtime
from sandboxer.models import Profile
@pytest.fixture
def artifact(tmp_path):
root = tmp_path / "runtime"
(root / "bin").mkdir(parents=True)
(root / "pyvenv.cfg").write_text("home = /usr/bin\n")
(root / "bin/python3").write_bytes(b"test-only-python")
(root / "bin/python3").chmod(0o755)
return root
def test_runtime_mount_requires_matching_complete_digest(artifact, tmp_path):
config = {"runtime": {"path": str(artifact), "sha256": runtime_digest(artifact)}}
assert verified_runtime(config) == artifact
argv = BwrapExtension(config)._bwrap_argv(str(tmp_path / "workspace"))
position = argv.index(str(artifact))
assert argv[position - 1:position + 2] == ["--ro-bind", str(artifact), RUNTIME_MOUNT]
assert argv[-1] == "--runtime"
(artifact / "bin/python3").write_bytes(b"modified")
with pytest.raises(ValueError, match="digest"):
BwrapExtension(config)._bwrap_argv(str(tmp_path / "workspace"))
def test_mode_change_invalidates_runtime_pin(artifact):
config = {"runtime": {"path": str(artifact), "sha256": runtime_digest(artifact)}}
(artifact / "bin/python3").chmod(0o777)
with pytest.raises(ValueError, match="digest"):
verified_runtime(config)
def test_unexpected_file_invalidates_runtime_pin(artifact):
config = {"runtime": {"path": str(artifact), "sha256": runtime_digest(artifact)}}
(artifact / "injected.py").write_text("unexpected")
with pytest.raises(ValueError, match="digest"):
verified_runtime(config)
def test_runtime_symlinks_must_stay_inside_artifact(artifact, tmp_path):
(artifact / "python").symlink_to("bin/python3")
runtime_digest(artifact)
outside = tmp_path / "outside"
outside.write_text("host data")
(artifact / "escape").symlink_to(outside)
with pytest.raises(ValueError, match="symlink escapes"):
runtime_digest(artifact)
def test_runtime_rejects_socket(artifact):
with socket.socket(socket.AF_UNIX) as control:
control.bind(str(artifact / "host.sock"))
with pytest.raises(ValueError, match="special file"):
runtime_digest(artifact)
@pytest.mark.parametrize("runtime", [{}, {"path": "/tmp"}, {"path": 3, "sha256": "x"}])
def test_incomplete_runtime_config_refuses(runtime):
with pytest.raises(ValueError):
verified_runtime({"runtime": runtime})
def test_source_and_runtime_overlap_refuses_before_copy(artifact):
ext = BwrapExtension({"runtime": {"path": str(artifact),
"sha256": runtime_digest(artifact)}})
profile = Profile(id="profile.test", version="1", extension="ext.bwrap")
with (
patch("sandboxer.extensions.bwrap.shutil.copytree") as copy,
pytest.raises(ValueError, match="source checkout"),
):
ext.provision(profile, {"repo": str(artifact.parent)}, "localhost")
copy.assert_not_called()
@pytest.mark.parametrize("network", [
{"default": "allow", "egress": []},
{"default": "deny", "egress": ["api.example.invalid:443"]},
])
def test_unsupported_network_refuses_before_provisioning(tmp_path, network):
profile = Profile(id="profile.test", version="1", extension="ext.bwrap", network=network)
base = tmp_path / "sandboxes"
with pytest.raises(ValueError, match="default-deny|owner allowlist"):
BwrapExtension({"base_dir": str(base)}).provision(profile, {}, "localhost")
assert not base.exists()
def test_runtime_path_is_not_taken_from_command_payload(tmp_path):
process = MagicMock(returncode=0)
process.communicate.return_value = (b"", b"")
payload = {"command": ["rein-aharness", "--help"], "timeout_seconds": 10,
"max_output_bytes": 100, "runtime_path": "/host/source", "env": {"HOME": "/host"}}
with patch("sandboxer.extensions.bwrap_runner.subprocess.Popen", return_value=process) as run:
_run(payload, tmp_path, runtime_enabled=True)
env = run.call_args.kwargs["env"]
assert env["PATH"].split(":")[0] == RUNTIME_MOUNT + "/bin"
assert not Path(env["HOME"]).is_relative_to(tmp_path)
assert "/host" not in str(env)
assert env["PYTHONNOUSERSITE"] == "1"
def test_setup_credentials_refuse_before_provisioning(tmp_path):
profile = Profile(id="profile.test", version="1", extension="ext.bwrap",
setup={"secret_refs": ["test-route"]})
base = tmp_path / "sandboxes"
with pytest.raises(ValueError, match="credential delivery contract"):
BwrapExtension({"base_dir": str(base)}).provision(profile, {}, "localhost")
assert not base.exists()

115
tests/test_egress.py Normal file
View file

@ -0,0 +1,115 @@
import socket
from unittest.mock import patch
import pytest
from sandboxer.extensions.egress import connect_public, destinations, tunnel
@pytest.mark.parametrize(
"entry",
[
"*",
"api.anthropic.com",
"api.anthropic.com:80",
"127.0.0.1:443",
"API.anthropic.com:443",
"api.anthropic.com.evil:443/path",
"x@:443",
],
)
def test_invalid_destination(entry):
with pytest.raises(ValueError):
destinations([entry])
@pytest.mark.parametrize(
"address", ["127.0.0.1", "10.0.0.1", "169.254.169.254", "::1", "::ffff:127.0.0.1"]
)
def test_nonpublic_dns_refused(address):
with (
patch("socket.getaddrinfo", return_value=[(socket.AF_INET, 1, 6, "", (address, 443))]),
patch("socket.socket") as factory,
pytest.raises(ValueError),
):
connect_public("api.anthropic.com")
factory.assert_not_called()
@pytest.mark.parametrize(
"wire_request",
[
b"CONNECT evil.example:443 HTTP/1.1\r\n\r\n",
b"CONNECT api.anthropic.com:80 HTTP/1.1\r\n\r\n",
b"GET https://api.anthropic.com/ HTTP/1.1\r\n\r\n",
b"CONNECT api.anthropic.com:443 HTTP/1.1\r\nHost: evil.example:443\r\n\r\n",
b"CONNECT api.anthropic.com:443 HTTP/1.1\r\nContent-Length: 1\r\n\r\n",
],
)
def test_denied_connect_never_dials(wire_request):
left, right = socket.socketpair()
with left, right, patch("sandboxer.extensions.egress.connect_public") as connect:
left.sendall(wire_request)
with pytest.raises(ValueError):
tunnel(right, destinations(["api.anthropic.com:443"]))
connect.assert_not_called()
def test_valid_connect_preserves_tls_bytes():
left, right = socket.socketpair()
upstream, peer = socket.socketpair()
with left, right, upstream, peer:
left.sendall(
b"CONNECT api.anthropic.com:443 HTTP/1.1\r\nHost: api.anthropic.com:443\r\n\r\nTLS"
)
with (
patch("sandboxer.extensions.egress.connect_public", return_value=upstream) as connect,
patch("sandboxer.extensions.egress.relay") as relay,
):
tunnel(right, destinations(["api.anthropic.com:443"]))
connect.assert_called_once_with("api.anthropic.com")
relay.assert_called_once_with(right, upstream)
assert right.recv(3) == b"TLS"
assert b"200 Connection Established" in left.recv(100)
def test_dns_result_is_used_without_second_resolution():
with (
patch(
"socket.getaddrinfo",
return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("1.1.1.1", 443))],
) as dns,
patch("socket.socket") as factory,
):
assert connect_public("api.anthropic.com") is factory.return_value
dns.assert_called_once()
factory.return_value.connect.assert_called_once_with(("1.1.1.1", 443))
def test_profile_cannot_expand_owner_allowlist(tmp_path):
from sandboxer.extensions.bwrap import BwrapExtension
from sandboxer.models import Profile
ext = BwrapExtension(
{"base_dir": str(tmp_path / "unused"), "allowed_egress": ["api.anthropic.com:443"]}
)
profile = Profile(
id="test",
version="1",
extension="ext.bwrap",
network={"default": "deny", "egress": ["example.com:443"]},
)
with pytest.raises(ValueError, match="owner allowlist"):
ext.provision(profile, {}, "localhost")
assert not (tmp_path / "unused").exists()
def test_failed_broker_readiness_removes_egress():
from sandboxer.extensions.bwrap import BwrapExtension
ext = BwrapExtension()
handle = {"egress_pid": "123"}
with patch.object(ext, "_wait_ready", side_effect=RuntimeError("startup failed")), \
patch.object(ext, "teardown") as cleanup:
with pytest.raises(RuntimeError, match="startup failed"):
ext.wait_ready(handle)
cleanup.assert_called_once_with(handle)

View file

@ -8,7 +8,7 @@ status: active
owner: codex
topic_slug: owner-mediated-execution
created: "2026-09-04"
updated: "2026-09-04"
updated: "2026-09-05"
state_hub_workstream_id: "b616d1cd-208f-5ecf-a4a0-a028396422c4"
---
@ -112,9 +112,16 @@ 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.
This task depends on a reviewed Glas profile revision and its matching
credential/egress/runtime contract. Glas selected the Claude route first in
GLAS-WP-0012; the earlier OpenRouter AppRole dependency applies only to the
separate open-weight profile and does not establish Claude authentication.
2026-09-05: SAND-WP-0015 implements pinned Python runtime mounts and private
namespace state, with a real rein CLI startup proof. Claude workload credential
routing, enforced provider egress, pinning/deploying the Claude executable,
and the real-model acceptance remain open in SAND-WP-0015-T04. No credential
value belongs in this workplan or State Hub.
## Acceptance criteria

View file

@ -0,0 +1,119 @@
---
id: SAND-WP-0015
type: workplan
title: "Provide a pinned bwrap rein runtime and private state"
domain: infotech
repo: sand-boxer
status: blocked
owner: codex
topic_slug: bwrap-runtime-and-private-state
created: "2026-09-05"
updated: "2026-09-05"
state_hub_workstream_id: "d3f12387-fd23-58f0-b979-9c811507614d"
---
# Provide a pinned bwrap rein runtime and private state
Implement the runtime prerequisites returned by Glas in `GLAS-WP-0012` and
`docs/local-profile-acceptance.md`. Continue owner work `SAND-WP-0014-T05` and
live residual `GLAS-IN-0002`. Runtime startup is a separate acceptance gate
from credential delivery, enforced provider egress, and a real model task.
## Mount an owner-selected, digest-pinned Python runtime
```task
id: SAND-WP-0015-T01
status: done
priority: high
state_hub_task_id: "93e4aad8-ba9b-5850-93d4-b5bf78ac8978"
```
Add exact artifact verification to trusted extension configuration and mount
the standalone runtime read-only at a fixed namespace path. Reject altered
content/modes, unexpected files, escaping symlinks, special files, and source
or workspace overlap. A caller cannot choose runtime paths through exec input.
Provide a non-editable rein-aharness/llm-connect bundle builder with recorded
source revisions and resolved dependency versions.
Completed 2026-09-05. `extensions/runtime.py` verifies complete artifact
contents/modes before a read-only mount; the builder installed committed
rein-aharness `1429db5` and llm-connect `0056094` without editable source paths.
Candidate digest and source/package evidence are in `docs/bwrap-runtime.md`.
## Keep writable runtime state outside the repository
```task
id: SAND-WP-0015-T02
status: done
priority: high
state_hub_task_id: "c8a689f8-bdf2-57a2-8521-a1c0ca44e750"
```
Create private mode-0700 HOME/config/cache/state/tmp directories within the
namespace, preserve them across exec requests, and remove them with teardown.
Keep the command environment sanitized and disable Python user-site/bytecode
writes. Fail closed on profile network/setup-credential declarations that the
current bwrap implementation cannot honor.
Completed 2026-09-05. Mode-0700 namespace HOME and XDG/TMP directories sit
outside the copied Git tree. Explicit runtime PATH selection remains owner
controlled. Unsupported egress/default-allow and setup credentials now refuse
before workspace creation; regression tests cover these boundaries.
## Verify the real rein runtime and owner regression paths
```task
id: SAND-WP-0015-T03
status: done
priority: high
state_hub_task_id: "1a3002f6-c6a8-59d0-9d38-942028419de5"
```
Run `make check`, the existing authenticated cross-request owner smoke, and a
real runtime startup smoke. Verify the actual rein CLI and adapter imports,
read-only runtime, private HOME, clean worktree, absent source, loopback-only
network, and teardown. Keep the production Glas profile blocked.
Completed 2026-09-05. `make check`: lint clean, 132 tests passed. Authenticated
owner API smoke `223db65b` returned HTTP 200 and proved exact consumer identity,
stdin delivery, absent source, loopback-only network and complete teardown.
Pinned runtime smoke `d4de9531` ran the actual rein CLI and imported its Claude
adapter, proved read-only runtime/private HOME/clean worktree, retained private
state across a second exec, and removed the workspace. No model call or
credential acquisition occurred; T04 remains waiting.
## Resolve Claude credentials, enforced egress, and production acceptance
```task
id: SAND-WP-0015-T04
status: wait
priority: high
state_hub_task_id: "58817ef6-76d9-5e34-908f-c024e6c99f93"
```
The 2026-09-05 routing lookup found no concrete Anthropic/Claude workload lane.
The generic OpenBao template is not a delegable lane; the OpenRouter AppRole
belongs to another rein. Credential owner railiance-platform/OpenBao plus
rein-aharness must establish a concrete Claude-compatible route and delivery/
revocation contract before values can be requested or supplied to the runtime.
Sand-boxer must then implement the matching enforced provider egress contract
(including DNS/TLS and denied-destination proof) and pin/deploy the actual
Claude executable. No unrestricted-network or interactive-login substitute is
permitted. Review the deployed runtime/profile with Glas, run its real-rein
acceptance fixture, and update only the proven profile's readiness. No
production schedule is enabled by the runtime-startup smoke.
This task and SAND-WP-0014-T05 remain open until those gates pass. The detailed
return contract and runtime proof are in `docs/bwrap-runtime.md`; the live
cross-repo residual remains `GLAS-IN-0002`.
## 2026-09-05 transport implementation evidence
T04 remains waiting on owner machine authentication, protected credential
delivery, pinned Claude startup and the real model proof. CCR-2026-0016 custody
is confirmed by a metadata-only check of live version 2; no value was read.
HTTPS egress is now implemented as an exact owner/profile allowlisted CONNECT
proxy while retaining an isolated network namespace. Non-secret live smoke
sandbox e290e788 verified provider TLS response, undeclared destination and
direct-IP denial, and proxy/workspace teardown. See docs/bwrap-egress.md.