sand-boxer/docs/bwrap-runtime.md
tegwick 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

123 lines
5.7 KiB
Markdown

# 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.