feat: pin bwrap rein runtimes and isolate private state
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0726e-5232-73f2-aaca-2c05ceb62efb
This commit is contained in:
parent
c2886b2f77
commit
d69827aaa2
12 changed files with 639 additions and 9 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
123
docs/bwrap-runtime.md
Normal file
123
docs/bwrap-runtime.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# 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 now refuses a profile with `network.default: allow`, nonempty egress, or
|
||||
setup secret references. The implementation currently provides only a
|
||||
loopback-only network and 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.
|
||||
67
scripts/build-rein-runtime.py
Normal file
67
scripts/build-rein-runtime.py
Normal 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())
|
||||
105
scripts/smoke-bwrap-runtime.py
Normal file
105
scripts/smoke-bwrap-runtime.py
Normal 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())
|
||||
|
|
@ -15,6 +15,7 @@ from pathlib import Path
|
|||
from typing import Any
|
||||
|
||||
from sandboxer.extensions.base import SandboxExtension
|
||||
from sandboxer.extensions.runtime import RUNTIME_MOUNT, verified_runtime
|
||||
from sandboxer.models import Profile
|
||||
|
||||
|
||||
|
|
@ -72,6 +73,13 @@ 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"]
|
||||
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 +88,8 @@ class BwrapExtension(SandboxExtension):
|
|||
workspace_dir,
|
||||
f"{workspace_dir}/{self.control_socket_name}",
|
||||
]
|
||||
if runtime is not None:
|
||||
argv.append("--runtime")
|
||||
return argv
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -102,6 +112,15 @@ class BwrapExtension(SandboxExtension):
|
|||
def provision(
|
||||
self, profile: Profile, inputs: dict[str, str], host: str
|
||||
) -> dict[str, str]:
|
||||
if profile.network.default != "deny" or profile.network.egress:
|
||||
raise ValueError("bwrap currently supports only default-deny with empty egress")
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) -> dict[str, object]:
|
||||
command = payload["command"]
|
||||
timeout_seconds = int(payload["timeout_seconds"])
|
||||
max_output_bytes = int(payload["max_output_bytes"])
|
||||
|
|
@ -31,12 +33,20 @@ 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"]
|
||||
timed_out = False
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
|
|
@ -73,6 +83,13 @@ 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 = sys.argv[3:] == ["--runtime"]
|
||||
if sys.argv[3:] and not runtime_enabled:
|
||||
raise ValueError("invalid owner runtime mode")
|
||||
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 +112,9 @@ 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
|
||||
)
|
||||
except Exception as exc:
|
||||
response = {"boundary_error": str(exc)}
|
||||
connection.sendall(json.dumps(response).encode("utf-8"))
|
||||
|
|
|
|||
52
src/sandboxer/extensions/runtime.py
Normal file
52
src/sandboxer/extensions/runtime.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
116
tests/test_bwrap_runtime.py
Normal 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="empty egress"):
|
||||
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()
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
109
workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md
Normal file
109
workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
---
|
||||
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`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue