Add owner-mediated bwrap execution boundary

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a06def-6490-7033-8448-2eab2d12ed44
This commit is contained in:
tegwick 2026-09-04 22:12:19 +02:00
parent 877676d1f1
commit d79e3fe358
23 changed files with 1321 additions and 86 deletions

View file

@ -17,6 +17,14 @@ Optional (SaaS): `estimate_cost(profile, duration) → MeterQuote`
Optional (checkpoints): `supports_snapshots()`, `snapshot(handle)`,
`restore_from_snapshot(profile, snapshot_meta, inputs, host)`
Optional (owner-mediated execution): `supports_execution()`, then
`execute(handle, command, credential_route_refs, execution_context,
timeout_seconds, max_output_bytes)`. The default implementation fails closed.
`ext.bwrap` is the reference implementation. An executing extension must
validate that its workspace belongs to the exact sandbox handle, sanitize the
child environment, use an argument vector, enforce the requested bounds, and
must not fall back to a host workspace.
### Base class
```python
@ -37,6 +45,7 @@ Reference implementations:
| `ext.saas-stub` | `saas_stub.py` | Metered stub + metadata snapshots |
| `ext.e2b` | `e2b.py` | E2B cloud adapter |
| `ext.modal` | `modal.py` | Modal cloud adapter |
| `ext.bwrap` | `bwrap.py` | Local namespaces + owner-mediated execution |
## Registration
@ -111,4 +120,4 @@ Implement `estimate_cost` and `meter_actual` on `SandboxExtension`. Register wit
| Packer build orchestration from `create` | Future WP |
| Daytona OSS cloud adapter | Future WP |
| fin-hub billing export | Future |
| Cross-host snapshot transfer | Future |
| Cross-host snapshot transfer | Future |

View file

@ -10,7 +10,9 @@ sandboxer create \
--profile profile.agent-dev \
--input repo=/path/to/workspace \
--actor agt \
--project glas-harness
--project glas-harness \
--session-id session-123 \
--run-id run-456
```
## Response fields (ready state)
@ -22,28 +24,31 @@ sandboxer create \
| `reachability.remote_dir` | sand-boxer | Workspace root on remote host |
| `state` | sand-boxer | Lifecycle state (`ready`, etc.) |
## Two reachability modes
## Two execution modes
Not every sandbox has an SSH hop. `ext.compose-ssh` / `ext.vm-packer`
always run remote and populate `reachability.ssh` + `reachability.
remote_dir`; glas-harness execs tools over that SSH channel. `ext.bwrap`
(SAND-WP-0013) runs same-host and never populates `reachability.ssh`
instead it populates `reachability.pid` (the placeholder process holding
the bwrap namespaces) and `reachability.workspace_dir`. glas-harness
execs tools by entering that pid's namespaces directly
(`nsenter --target <pid> --mount --pid --net --uts --ipc -- <cmd>`, no
tunnel needed) rather than opening an SSH channel. `reachability/enrich.
build_reachability_report()` returns both an `ssh_one_liner` and a
`local_exec_hint`; exactly one is non-null depending on which mode the
resolved extension uses. Consumers should branch on which field is
populated, not on profile id, since routing can fall back between
extensions.
the bwrap namespaces) and `reachability.workspace_dir` as evidence. These
fields are not a consumer attach contract. `build_reachability_report()`
returns `execution.mode: owner-mediated`; glas-harness sends an exec request
to the resident sand-boxer owner service. Direct `nsenter` is unsupported.
The request repeats the exact actor/project/session/run identity used at
create, carries an argument vector (never a shell command string), and may
carry only value-free credential catalog route references. sand-boxer refuses
identity mismatch, non-ready or expired state, concurrent execution, and every
extension without an owner execution implementation. It never retries against
the host source checkout.
## Ownership
| Concern | Owner |
|---------|-------|
| Sandbox provision / teardown | sand-boxer |
| In-namespace command broker, workspace cwd, timeout/output bounds | sand-boxer |
| Tool call parsing and policies | glas-harness |
| SSH / tunnel reachability setup | glas-harness + ops-bridge |
| Agent memory and session state | glas-harness |
@ -62,4 +67,5 @@ one-liner), then destroys.
- Tool schemas and approval flows
- Channel bridges (Slack, email, etc.)
- Subagent orchestration
- Subagent orchestration
- Provider credential acquisition or injection (the selected rein owns it)

View file

@ -92,7 +92,7 @@ Extends the `build-agent` self-register pattern: generic sandbox identities carr
| `recreate` | Destroy and reprovision from stored seed | **Yes** |
| `destroy` | Idempotent teardown | **Yes** |
| `snapshot` / `restore` | Checkpoint workspace | **Yes** (compose-ssh, saas-stub) |
| `exec` | Run command in sandbox | Harness-owned via SSH (glas-harness) |
| `exec` | Owner-mediated command in a local bwrap sandbox | **Yes** (`ext.bwrap` only) |
HTTP surface (optional v0; CLI calls core library directly):
@ -107,6 +107,7 @@ HTTP surface (optional v0; CLI calls core library directly):
- `PATCH /v1/sandboxes/{id}/ttl` — extend TTL
- `POST /v1/sandboxes/expire` — TTL reap (query `apply=true`)
- `GET /v1/sandboxes/{id}/reachability` — enriched descriptor + SSH one-liner
- `POST /v1/sandboxes/{id}/exec` — authenticated owner-mediated bwrap command
---
@ -133,6 +134,12 @@ Tunnel metadata is enriched from profile `reachability` and environment:
sand-boxer **does not** bring tunnels up. Consumers use ops-bridge (MCP or
`bridge` CLI) to attach SSH routes; the descriptor is a pointer only.
For `ext.bwrap`, reachability is not an invitation to enter the recorded PID.
The report contains `execution.mode: owner-mediated`; callers use `sandboxer
exec` when running as the sandbox owner or the authenticated HTTP exec endpoint
when calling the resident owner service. Direct consumer-side `nsenter` is not
supported.
`secret_refs` from `profile.setup` are resolved at the provision boundary and
passed to the extension handle — they never appear on `SandboxStatus` or State
Hub events.
@ -159,6 +166,8 @@ consumer:
sand-boxer records attribution on every lifecycle event. It does not interpret
agent intent or authorize the caller — flex-auth owns authorization when enforced.
An exec request must nevertheless repeat this block exactly. Any actor, project,
session, or run mismatch is refused before extension dispatch.
---
@ -170,6 +179,8 @@ Each extension implements:
provision(profile, inputs, placement) → SandboxHandle
wait_ready(handle) → Reachability
teardown(handle) → CleanupReport
supports_execution?() → bool
execute?(handle, argv, credential_route_refs, execution_context, bounds) → CommandResult
estimate_cost?(profile, duration) → MeterQuote # optional; SaaS only
```
@ -210,6 +221,9 @@ sand-boxer commits to:
from Railiance01 production
4. **Observable lifecycle** — every transition attributed to `adm` / `agt` / `atm`
5. **Honest limits** — allowed tool paths can be abused by compromised agents
6. **Fail-closed local execution** — exact identity/state/TTL binding, owner-managed
workspace validation, sanitized environment, bounded duration/output, and no
host-checkout or alternate-extension fallback
sand-boxer does **not** provide intent-aware egress filtering in v1.
@ -226,4 +240,4 @@ sand-boxer does **not** provide intent-aware egress filtering in v1.
| SSH certificates | ops-warden |
| Workstream / task state | state-hub |
See `docs/integrations/` for per-sibling contracts.
See `docs/integrations/` for per-sibling contracts.

View file

@ -0,0 +1,84 @@
# Runbook: profile.bwrap-local owner execution
`profile.bwrap-local` copies the requested repository into an owner-managed
workspace and starts a bubblewrap namespace with network default-deny. Commands
must cross the sand-boxer owner boundary; direct namespace entry is unsupported.
## Create with governed identity
```bash
sandboxer create \
--profile profile.bwrap-local \
--input repo=/path/to/repository \
--actor agt \
--project glas-harness \
--session-id session-123 \
--run-id run-456
```
Keep the returned sandbox id. The source path is copied; it is not mounted into
the namespace.
## Execute as the local owner
```bash
sandboxer exec SANDBOX_ID \
--actor agt \
--project glas-harness \
--session-id session-123 \
--run-id run-456 \
-- python3 -c 'from pathlib import Path; print(Path.cwd())'
```
The result records the bound identity, profile and extension, command name,
exit/timeout outcome, workspace, and declared network posture. Captured stdout
and stderr default to 256 KiB per stream and the timeout defaults to 15 minutes.
Credential values are never command options or environment assignments. A rein
that owns credential acquisition may receive only a catalog identifier, for
example `--credential-route-ref rein-openweights-openrouter-approle`; inside the
sandbox that appears as the value-free JSON list
`SANDBOXER_CREDENTIAL_ROUTE_REFS`. The selected rein resolves and cleans up the
credential through its approved owner-fronted route.
## Resident owner API
The high-risk HTTP route is disabled unless the service process receives
`SANDBOXER_EXEC_TOKEN` through its approved service credential delivery path.
Before provisioning that credential, locate its custody route with `warden route
find "sand-boxer owner execution service credential" --json`; do not paste it
into a shell history, workplan, State Hub, or sandbox mount.
Call `POST /v1/sandboxes/SANDBOX_ID/exec` with a bearer token and this body:
```json
{
"command": ["python3", "-V"],
"consumer": {
"actor": "agt",
"project": "glas-harness",
"session_id": "session-123",
"run_id": "run-456"
},
"credential_route_refs": [],
"timeout_seconds": 30,
"max_output_bytes": 65536
}
```
The bearer authenticates access to the owner service; exact consumer identity
matching additionally binds the command to the existing sandbox grant.
## Destroy
```bash
sandboxer destroy SANDBOX_ID
```
Destroy kills the namespace process group and removes the copied workspace.
For a non-secret create/execute/source-absence/destroy proof, run:
```bash
make smoke-bwrap-exec
```