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:
parent
877676d1f1
commit
d79e3fe358
23 changed files with 1321 additions and 86 deletions
7
Makefile
7
Makefile
|
|
@ -1,6 +1,6 @@
|
|||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help setup install test lint format build check cli-version smoke-remote
|
||||
.PHONY: help setup install test lint format build check cli-version smoke-remote smoke-bwrap-exec
|
||||
|
||||
help: ## List available make targets
|
||||
@awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_.-]+:.*## / {printf " %-16s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
|
@ -29,4 +29,7 @@ cli-version: ## Print CLI version (smoke test for entry point)
|
|||
uv run sandboxer version
|
||||
|
||||
smoke-remote: ## T10 remote create/destroy smoke (needs SANDBOXER_HOST)
|
||||
./scripts/smoke-compose-e2e.sh
|
||||
./scripts/smoke-compose-e2e.sh
|
||||
|
||||
smoke-bwrap-exec: ## Owner-mediated local bwrap create/exec/destroy proof
|
||||
uv run python scripts/smoke-bwrap-exec.py
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
| workplan | SAND-WP-0011 | finished | — | workplans/SAND-WP-0011-reachability-and-consumer-profiles.md |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
|
@ -111,3 +112,8 @@
|
|||
| task | SAND-WP-0013-T04 | done | — | workplans/SAND-WP-0013-bwrap-extension.md |
|
||||
| task | SAND-WP-0013-T05 | done | — | workplans/SAND-WP-0013-bwrap-extension.md |
|
||||
| task | SAND-WP-0013-T06 | done | — | workplans/SAND-WP-0013-bwrap-extension.md |
|
||||
| task | SAND-WP-0014-T01 | done | — | workplans/SAND-WP-0014-owner-mediated-execution.md |
|
||||
| task | SAND-WP-0014-T02 | done | — | workplans/SAND-WP-0014-owner-mediated-execution.md |
|
||||
| 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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
84
docs/runbooks/profile-bwrap-local.md
Normal file
84
docs/runbooks/profile-bwrap-local.md
Normal 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
|
||||
```
|
||||
88
scripts/smoke-bwrap-exec.py
Normal file
88
scripts/smoke-bwrap-exec.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Non-secret local proof for the owner-mediated bwrap execution boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from sandboxer.core.manager import SandboxManager
|
||||
from sandboxer.lifecycle.store import SandboxStore
|
||||
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest, SandboxExecRequest
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with tempfile.TemporaryDirectory(prefix="sandboxer-bwrap-proof-") as temp_value:
|
||||
temp_dir = Path(temp_value)
|
||||
source = temp_dir / "source"
|
||||
source.mkdir()
|
||||
(source / "copied.txt").write_text("copied\n")
|
||||
outside_sentinel = temp_dir / "host-source-sentinel"
|
||||
outside_sentinel.write_text("must-not-be-visible\n")
|
||||
|
||||
manager = SandboxManager(store=SandboxStore(path=temp_dir / "sandboxes.json"))
|
||||
consumer = Consumer(
|
||||
actor=ActorType.AGT,
|
||||
project="sand-boxer-smoke",
|
||||
session_id="non-secret-proof",
|
||||
run_id="non-secret-proof-1",
|
||||
)
|
||||
status = manager.create(
|
||||
SandboxCreateRequest(
|
||||
profile="profile.bwrap-local",
|
||||
inputs={"repo": str(source)},
|
||||
consumer=consumer,
|
||||
ttl="5m",
|
||||
)
|
||||
)
|
||||
try:
|
||||
proof_code = (
|
||||
"import json, os; from pathlib import Path; "
|
||||
"p=Path('proof.txt'); p.write_text('inside\\n'); "
|
||||
f"outside=Path({str(outside_sentinel)!r}).exists(); "
|
||||
"interfaces=[line.split(':',1)[0].strip() for line in "
|
||||
"Path('/proc/net/dev').read_text().splitlines()[2:]]; "
|
||||
"print(json.dumps({'cwd': str(Path.cwd()), "
|
||||
"'copied': Path('copied.txt').is_file(), 'artifact': p.is_file(), "
|
||||
"'host_source_visible': outside, "
|
||||
"'network_interfaces': interfaces, "
|
||||
"'actor': os.environ.get('SANDBOXER_ACTOR'), "
|
||||
"'run_id': os.environ.get('SANDBOXER_RUN_ID'), "
|
||||
"'credential_refs': os.environ.get('SANDBOXER_CREDENTIAL_ROUTE_REFS')}))"
|
||||
)
|
||||
result = manager.execute(
|
||||
status.sandbox_id,
|
||||
SandboxExecRequest(
|
||||
command=["/usr/bin/python3", "-c", proof_code],
|
||||
consumer=consumer,
|
||||
timeout_seconds=30,
|
||||
max_output_bytes=65_536,
|
||||
),
|
||||
)
|
||||
payload = result.model_dump(mode="json")
|
||||
try:
|
||||
proof = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
proof = None
|
||||
payload["proof"] = proof
|
||||
destroyed = manager.destroy(status.sandbox_id)
|
||||
payload["teardown"] = {
|
||||
"state": destroyed.state.value,
|
||||
"workspace_removed": not Path(result.workspace_dir).exists(),
|
||||
}
|
||||
print(json.dumps(payload, indent=2))
|
||||
passed = (
|
||||
result.exit_code == 0
|
||||
and proof is not None
|
||||
and not proof["host_source_visible"]
|
||||
and proof["network_interfaces"] == ["lo"]
|
||||
and payload["teardown"]["workspace_removed"]
|
||||
)
|
||||
return 0 if passed else 1
|
||||
finally:
|
||||
manager.destroy(status.sandbox_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -2,13 +2,18 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
import hmac
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI, Header, HTTPException
|
||||
|
||||
from sandboxer.core.manager import SandboxManager
|
||||
from sandboxer.models import (
|
||||
ExpireActionResult,
|
||||
ExtendTtlRequest,
|
||||
SandboxCreateRequest,
|
||||
SandboxExecRequest,
|
||||
SandboxExecResult,
|
||||
SandboxStatus,
|
||||
SnapshotRecord,
|
||||
SnapshotRestoreRequest,
|
||||
|
|
@ -18,6 +23,16 @@ app = FastAPI(title="sand-boxer", version="0.0.0")
|
|||
_manager = SandboxManager()
|
||||
|
||||
|
||||
def _authorize_exec(authorization: str | None) -> None:
|
||||
"""Require an explicit owner-service capability on the high-risk exec route."""
|
||||
expected = os.environ.get("SANDBOXER_EXEC_TOKEN")
|
||||
if not expected:
|
||||
raise HTTPException(status_code=503, detail="owner execution API is not configured")
|
||||
scheme, _, supplied = (authorization or "").partition(" ")
|
||||
if scheme.lower() != "bearer" or not hmac.compare_digest(supplied, expected):
|
||||
raise HTTPException(status_code=401, detail="invalid owner execution credential")
|
||||
|
||||
|
||||
@app.post("/v1/sandboxes", response_model=SandboxStatus)
|
||||
def create_sandbox(request: SandboxCreateRequest, host: str | None = None) -> SandboxStatus:
|
||||
try:
|
||||
|
|
@ -42,6 +57,25 @@ def get_sandbox_reachability(sandbox_id: str) -> dict:
|
|||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/v1/sandboxes/{sandbox_id}/exec", response_model=SandboxExecResult)
|
||||
def execute_in_sandbox(
|
||||
sandbox_id: str,
|
||||
request: SandboxExecRequest,
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> SandboxExecResult:
|
||||
_authorize_exec(authorization)
|
||||
try:
|
||||
return _manager.execute(sandbox_id, request)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get("/v1/sandboxes", response_model=list[SandboxStatus])
|
||||
def list_sandboxes() -> list[SandboxStatus]:
|
||||
return _manager.list()
|
||||
|
|
@ -117,4 +151,4 @@ def extend_sandbox_ttl(sandbox_id: str, request: ExtendTtlRequest) -> SandboxSta
|
|||
|
||||
@app.post("/v1/sandboxes/expire", response_model=list[ExpireActionResult])
|
||||
def expire_sandboxes(apply: bool = False) -> list[ExpireActionResult]:
|
||||
return _manager.expire(apply=apply)
|
||||
return _manager.expire(apply=apply)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import typer
|
|||
from sandboxer import __version__
|
||||
from sandboxer.core.manager import SandboxManager
|
||||
from sandboxer.defaults import resolve_create_defaults
|
||||
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest
|
||||
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest, SandboxExecRequest
|
||||
from sandboxer.payments.credits import CreditsStore
|
||||
from sandboxer.placement import resolve_host
|
||||
from sandboxer.profiles.loader import load_profile
|
||||
|
|
@ -93,6 +93,10 @@ def sandbox_create(
|
|||
project: Annotated[str, typer.Option(help="Calling project id")] = "sand-boxer",
|
||||
host: Annotated[str | None, typer.Option(help="Override placement host")] = None,
|
||||
ttl: Annotated[str | None, typer.Option(help="TTL override (e.g. 4h)")] = None,
|
||||
session_id: Annotated[
|
||||
str | None, typer.Option(help="Governed consumer session id")
|
||||
] = None,
|
||||
run_id: Annotated[str | None, typer.Option(help="Governed consumer run id")] = None,
|
||||
) -> None:
|
||||
"""Provision a sandbox. No args → canary self-deploy of sand-boxer.
|
||||
|
||||
|
|
@ -104,7 +108,12 @@ def sandbox_create(
|
|||
request = SandboxCreateRequest(
|
||||
profile=resolved_profile,
|
||||
inputs=resolved_inputs,
|
||||
consumer=Consumer(actor=ActorType(actor), project=project),
|
||||
consumer=Consumer(
|
||||
actor=ActorType(actor),
|
||||
project=project,
|
||||
session_id=session_id,
|
||||
run_id=run_id,
|
||||
),
|
||||
ttl=ttl,
|
||||
)
|
||||
manager = SandboxManager()
|
||||
|
|
@ -130,6 +139,51 @@ def reachability_show(sandbox_id: str) -> None:
|
|||
_print_json(report)
|
||||
|
||||
|
||||
@app.command("exec", context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
|
||||
def sandbox_exec(
|
||||
ctx: typer.Context,
|
||||
sandbox_id: Annotated[str, typer.Argument(help="Ready sandbox id")],
|
||||
actor: Annotated[str, typer.Option(help="Recorded consumer actor")],
|
||||
project: Annotated[str, typer.Option(help="Recorded consumer project")],
|
||||
session_id: Annotated[
|
||||
str | None, typer.Option(help="Recorded consumer session id")
|
||||
] = None,
|
||||
run_id: Annotated[str | None, typer.Option(help="Recorded consumer run id")] = None,
|
||||
credential_route_ref: Annotated[
|
||||
list[str] | None,
|
||||
typer.Option(help="Value-free credential catalog route (repeatable)"),
|
||||
] = None,
|
||||
timeout: Annotated[int, typer.Option(help="Command timeout in seconds")] = 900,
|
||||
max_output_bytes: Annotated[
|
||||
int, typer.Option(help="Per-stream captured output limit")
|
||||
] = 262_144,
|
||||
) -> None:
|
||||
"""Run COMMAND inside a bwrap sandbox through its owning process."""
|
||||
command = list(ctx.args)
|
||||
if command and command[0] == "--":
|
||||
command = command[1:]
|
||||
if not command:
|
||||
raise typer.BadParameter("COMMAND is required after --")
|
||||
request = SandboxExecRequest(
|
||||
command=command,
|
||||
consumer=Consumer(
|
||||
actor=ActorType(actor),
|
||||
project=project,
|
||||
session_id=session_id,
|
||||
run_id=run_id,
|
||||
),
|
||||
credential_route_refs=credential_route_ref or [],
|
||||
timeout_seconds=timeout,
|
||||
max_output_bytes=max_output_bytes,
|
||||
)
|
||||
try:
|
||||
result = SandboxManager().execute(sandbox_id, request)
|
||||
except (KeyError, PermissionError, RuntimeError, ValueError) as exc:
|
||||
typer.echo(f"Error: {exc}", err=True)
|
||||
raise typer.Exit(code=1) from exc
|
||||
_print_json(result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@app.command("get")
|
||||
def sandbox_get(sandbox_id: str) -> None:
|
||||
"""Get sandbox status by id."""
|
||||
|
|
@ -338,4 +392,4 @@ def credits_add(
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
app()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
|
||||
from sandboxer.extensions.registry import load_extension, resolve_backend
|
||||
from sandboxer.lifecycle.expire import (
|
||||
ExpireCandidate,
|
||||
|
|
@ -17,6 +21,8 @@ from sandboxer.models import (
|
|||
MeterRecord,
|
||||
Reachability,
|
||||
SandboxCreateRequest,
|
||||
SandboxExecRequest,
|
||||
SandboxExecResult,
|
||||
SandboxState,
|
||||
SandboxStatus,
|
||||
SnapshotRecord,
|
||||
|
|
@ -39,6 +45,8 @@ from sandboxer.telemetry.introspection import (
|
|||
|
||||
|
||||
class SandboxManager:
|
||||
_CREDENTIAL_ROUTE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: SandboxStore | None = None,
|
||||
|
|
@ -48,6 +56,7 @@ class SandboxManager:
|
|||
self.store = store or SandboxStore()
|
||||
self.credits = credits or CreditsStore()
|
||||
self.snapshots = snapshots or SnapshotStore()
|
||||
self._exec_locks: dict[str, Lock] = {}
|
||||
|
||||
@staticmethod
|
||||
def _handle_from_status(status: SandboxStatus) -> dict[str, str]:
|
||||
|
|
@ -207,6 +216,138 @@ class SandboxManager:
|
|||
|
||||
return build_reachability_report(status)
|
||||
|
||||
@staticmethod
|
||||
def _validate_exec_consumer(status: SandboxStatus, request: SandboxExecRequest) -> None:
|
||||
if request.consumer != status.consumer:
|
||||
raise PermissionError(
|
||||
"execution consumer does not exactly match sandbox "
|
||||
"actor/project/session/run identity"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _validate_exec_request(cls, request: SandboxExecRequest) -> None:
|
||||
if not request.command[0] or any("\x00" in arg for arg in request.command):
|
||||
raise ValueError("command arguments must be non-empty and contain no NUL bytes")
|
||||
command_size = sum(len(arg.encode("utf-8")) for arg in request.command)
|
||||
if command_size > 65_536:
|
||||
raise ValueError("command argument vector exceeds 65536 bytes")
|
||||
invalid_refs = [
|
||||
ref
|
||||
for ref in request.credential_route_refs
|
||||
if not cls._CREDENTIAL_ROUTE_RE.fullmatch(ref)
|
||||
]
|
||||
if invalid_refs:
|
||||
raise ValueError("credential route references must be value-free catalog identifiers")
|
||||
if len(set(request.credential_route_refs)) != len(request.credential_route_refs):
|
||||
raise ValueError("credential route references must be unique")
|
||||
|
||||
def execute(self, sandbox_id: str, request: SandboxExecRequest) -> SandboxExecResult:
|
||||
"""Run a command through the owning extension without a host fallback."""
|
||||
lock = self._exec_locks.setdefault(sandbox_id, Lock())
|
||||
if not lock.acquire(blocking=False):
|
||||
raise RuntimeError("Sandbox already has an active owner-mediated command")
|
||||
try:
|
||||
return self._execute_locked(sandbox_id, request)
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
def _execute_locked(
|
||||
self, sandbox_id: str, request: SandboxExecRequest
|
||||
) -> SandboxExecResult:
|
||||
status = self.store.get(sandbox_id)
|
||||
if not status:
|
||||
raise KeyError(f"Sandbox not found: {sandbox_id}")
|
||||
self._validate_exec_request(request)
|
||||
self._validate_exec_consumer(status, request)
|
||||
if status.state != SandboxState.READY:
|
||||
raise RuntimeError(
|
||||
f"Sandbox must be ready for execution (state={status.state.value})"
|
||||
)
|
||||
now = utcnow()
|
||||
if status.expires_at and now >= status.expires_at:
|
||||
raise RuntimeError("Sandbox TTL has expired")
|
||||
|
||||
profile = load_profile(status.profile_id)
|
||||
extension = load_extension(status.extension_id)
|
||||
backend = resolve_backend(extension)
|
||||
if not backend.supports_execution():
|
||||
raise RuntimeError(
|
||||
f"Extension {status.extension_id} has no owner-mediated execution boundary"
|
||||
)
|
||||
|
||||
command_name = Path(request.command[0]).name
|
||||
status.state = SandboxState.ACTIVE
|
||||
status.updated_at = now
|
||||
self.store.save(status)
|
||||
emit_lifecycle_event(
|
||||
status,
|
||||
summary=f"Owner-mediated command started ({command_name})",
|
||||
event_type=event_type_for_state(status.state),
|
||||
)
|
||||
|
||||
context = {
|
||||
"sandbox_id": status.sandbox_id,
|
||||
"profile_id": status.profile_id,
|
||||
"actor": status.consumer.actor.value,
|
||||
"project": status.consumer.project,
|
||||
}
|
||||
if status.consumer.session_id:
|
||||
context["session_id"] = status.consumer.session_id
|
||||
if status.consumer.run_id:
|
||||
context["run_id"] = status.consumer.run_id
|
||||
|
||||
try:
|
||||
execution = backend.execute(
|
||||
self._handle_from_status(status),
|
||||
request.command,
|
||||
credential_route_refs=request.credential_route_refs,
|
||||
execution_context=context,
|
||||
timeout_seconds=request.timeout_seconds,
|
||||
max_output_bytes=request.max_output_bytes,
|
||||
)
|
||||
except Exception as exc:
|
||||
status.state = SandboxState.READY
|
||||
status.updated_at = utcnow()
|
||||
self.store.save(status)
|
||||
emit_lifecycle_event(
|
||||
status,
|
||||
summary=f"Owner-mediated command boundary failed ({command_name}): {exc}",
|
||||
event_type="note",
|
||||
)
|
||||
raise
|
||||
|
||||
completed_at = utcnow()
|
||||
status.state = SandboxState.READY
|
||||
status.updated_at = completed_at
|
||||
self.store.save(status)
|
||||
emit_lifecycle_event(
|
||||
status,
|
||||
summary=(
|
||||
f"Owner-mediated command completed ({command_name}, "
|
||||
f"exit={execution['exit_code']}, timed_out={execution['timed_out']})"
|
||||
),
|
||||
event_type=event_type_for_state(status.state),
|
||||
)
|
||||
return SandboxExecResult(
|
||||
sandbox_id=status.sandbox_id,
|
||||
profile_id=status.profile_id,
|
||||
extension_id=status.extension_id,
|
||||
consumer=status.consumer,
|
||||
command_name=command_name,
|
||||
exit_code=int(execution["exit_code"]),
|
||||
timed_out=bool(execution["timed_out"]),
|
||||
stdout=str(execution["stdout"]),
|
||||
stderr=str(execution["stderr"]),
|
||||
output_truncated=bool(execution["output_truncated"]),
|
||||
duration_seconds=max(0.0, (completed_at - now).total_seconds()),
|
||||
workspace_dir=str(execution["workspace_dir"]),
|
||||
network_default=profile.network.default,
|
||||
network_egress=profile.network.egress,
|
||||
credential_route_refs=request.credential_route_refs,
|
||||
started_at=now,
|
||||
completed_at=completed_at,
|
||||
)
|
||||
|
||||
def list(self) -> list[SandboxStatus]:
|
||||
return sorted(self.store.list_all(), key=lambda s: s.created_at, reverse=True)
|
||||
|
||||
|
|
@ -510,4 +651,4 @@ class SandboxManager:
|
|||
summary=f"Snapshot restore failed: {exc}",
|
||||
event_type=event_type_for_state(status.state),
|
||||
)
|
||||
raise
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -47,6 +47,23 @@ class SandboxExtension(ABC):
|
|||
"""Optional post-destroy actual cost in USD."""
|
||||
return None
|
||||
|
||||
def supports_execution(self) -> bool:
|
||||
"""Whether the owner can run a command inside an established sandbox."""
|
||||
return False
|
||||
|
||||
def execute(
|
||||
self,
|
||||
handle: dict[str, str],
|
||||
command: list[str],
|
||||
*,
|
||||
credential_route_refs: list[str],
|
||||
execution_context: dict[str, str],
|
||||
timeout_seconds: int,
|
||||
max_output_bytes: int,
|
||||
) -> dict[str, object]:
|
||||
"""Run a bounded command through an owner-mediated sandbox boundary."""
|
||||
raise NotImplementedError(f"{type(self).__name__} does not support execution")
|
||||
|
||||
def supports_snapshots(self) -> bool:
|
||||
"""Whether this extension implements checkpoint snapshot/restore."""
|
||||
return False
|
||||
|
|
@ -63,4 +80,4 @@ class SandboxExtension(ABC):
|
|||
host: str,
|
||||
) -> dict[str, str]:
|
||||
"""Provision a new sandbox from a prior checkpoint."""
|
||||
raise NotImplementedError(f"{type(self).__name__} does not support restore")
|
||||
raise NotImplementedError(f"{type(self).__name__} does not support restore")
|
||||
|
|
|
|||
|
|
@ -2,10 +2,15 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -19,8 +24,8 @@ class BwrapExtension(SandboxExtension):
|
|||
Unlike ext.compose-ssh / ext.vm-packer, this extension never leaves the
|
||||
local host: no SSH hop, no container runtime, no remote placement. A new
|
||||
user/mount/pid/ipc/uts/net namespace is created per sandbox, kept alive
|
||||
by a long-running placeholder process (`sleep infinity`) whose pid is
|
||||
the handle's exec target. `--unshare-net` with no veth/interface makes
|
||||
by a minimal command broker whose namespace pid is retained for lifecycle
|
||||
evidence and teardown. `--unshare-net` with no veth/interface makes
|
||||
`network.default: deny` real, rather than declarative-only like the
|
||||
other self-hosted extensions.
|
||||
"""
|
||||
|
|
@ -30,6 +35,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.ro_binds: list[str] = cfg.get(
|
||||
"ro_binds", ["/usr", "/bin", "/lib", "/lib64", "/etc/resolv.conf"]
|
||||
)
|
||||
|
|
@ -40,7 +48,7 @@ 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) -> list[str]:
|
||||
def _bwrap_argv(self, workspace_dir: str, *, info_fd: int | None = None) -> list[str]:
|
||||
argv = [
|
||||
self._bwrap_bin(),
|
||||
"--die-with-parent",
|
||||
|
|
@ -56,14 +64,42 @@ class BwrapExtension(SandboxExtension):
|
|||
"/proc",
|
||||
"--dev",
|
||||
"/dev",
|
||||
"--clearenv",
|
||||
]
|
||||
if info_fd is not None:
|
||||
argv += ["--info-fd", str(info_fd)]
|
||||
for path in self._existing_ro_binds():
|
||||
argv += ["--ro-bind", path, path]
|
||||
runner = Path(__file__).with_name("bwrap_runner.py")
|
||||
argv += ["--dir", "/run", "--dir", "/run/sandboxer"]
|
||||
argv += ["--ro-bind", str(runner), "/run/sandboxer/bwrap_runner.py"]
|
||||
argv += ["--bind", workspace_dir, workspace_dir]
|
||||
argv += ["--chdir", workspace_dir]
|
||||
argv += ["sleep", "infinity"]
|
||||
argv += [
|
||||
"/usr/bin/python3",
|
||||
"/run/sandboxer/bwrap_runner.py",
|
||||
workspace_dir,
|
||||
f"{workspace_dir}/{self.control_socket_name}",
|
||||
]
|
||||
return argv
|
||||
|
||||
@staticmethod
|
||||
def _read_child_pid(proc: subprocess.Popen, info_fd: int) -> int:
|
||||
ready, _, _ = select.select([info_fd], [], [], 10)
|
||||
if not ready:
|
||||
proc.kill()
|
||||
raise RuntimeError("timed out waiting for bwrap namespace child pid")
|
||||
raw = os.read(info_fd, 16_384)
|
||||
try:
|
||||
child_pid = int(json.loads(raw)["child-pid"])
|
||||
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
|
||||
proc.kill()
|
||||
raise RuntimeError("bwrap did not report a valid namespace child pid") from exc
|
||||
if child_pid <= 0:
|
||||
proc.kill()
|
||||
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]:
|
||||
|
|
@ -77,19 +113,30 @@ class BwrapExtension(SandboxExtension):
|
|||
if not repo_path.exists():
|
||||
raise FileNotFoundError(f"Repo path does not exist: {repo_path}")
|
||||
shutil.copytree(repo_path, workspace_dir, dirs_exist_ok=True)
|
||||
Path(workspace_dir).chmod(0o700)
|
||||
|
||||
argv = self._bwrap_argv(workspace_dir)
|
||||
proc = subprocess.Popen(
|
||||
argv,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
info_read_fd, info_write_fd = os.pipe()
|
||||
try:
|
||||
argv = self._bwrap_argv(workspace_dir, info_fd=info_write_fd)
|
||||
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)
|
||||
|
||||
return {
|
||||
"sandbox_id": sandbox_id,
|
||||
"host": host,
|
||||
"pid": str(proc.pid),
|
||||
"pid": str(child_pid),
|
||||
"supervisor_pid": str(proc.pid),
|
||||
"workspace_dir": workspace_dir,
|
||||
}
|
||||
|
||||
|
|
@ -100,11 +147,79 @@ class BwrapExtension(SandboxExtension):
|
|||
workspace_dir = handle["workspace_dir"]
|
||||
if not Path(workspace_dir).is_dir():
|
||||
raise RuntimeError(f"workspace missing: {workspace_dir}")
|
||||
control_socket = Path(workspace_dir) / self.control_socket_name
|
||||
deadline = time.monotonic() + 5
|
||||
while not control_socket.is_socket():
|
||||
if not self._pid_alive(pid):
|
||||
raise RuntimeError(f"bwrap process {pid} exited before control became ready")
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError("bwrap owner control socket did not become ready")
|
||||
time.sleep(0.05)
|
||||
return {
|
||||
"host": handle.get("host", "localhost"),
|
||||
"endpoint": f"pid:{pid}",
|
||||
}
|
||||
|
||||
def supports_execution(self) -> bool:
|
||||
return True
|
||||
|
||||
def _validated_workspace(self, handle: dict[str, str]) -> Path:
|
||||
sandbox_id = handle.get("sandbox_id", "")
|
||||
if not sandbox_id or "/" in sandbox_id or sandbox_id in {".", ".."}:
|
||||
raise RuntimeError("invalid sandbox id in execution handle")
|
||||
workspace_value = handle.get("workspace_dir", "")
|
||||
if not workspace_value:
|
||||
raise RuntimeError("sandbox execution handle has no workspace")
|
||||
workspace = Path(workspace_value).resolve(strict=True)
|
||||
expected = (Path(self.base_dir).resolve() / sandbox_id).resolve()
|
||||
if workspace != expected or not workspace.is_dir():
|
||||
raise RuntimeError("refusing execution outside owner-managed sandbox workspace")
|
||||
return workspace
|
||||
|
||||
def execute(
|
||||
self,
|
||||
handle: dict[str, str],
|
||||
command: list[str],
|
||||
*,
|
||||
credential_route_refs: list[str],
|
||||
execution_context: dict[str, str],
|
||||
timeout_seconds: int,
|
||||
max_output_bytes: int,
|
||||
) -> dict[str, object]:
|
||||
"""Ask the broker already inside bwrap to run an argument-vector command."""
|
||||
pid = int(handle.get("pid", "0"))
|
||||
if pid <= 0 or not self._pid_alive(pid):
|
||||
raise RuntimeError(f"bwrap process {pid} is not running")
|
||||
workspace = self._validated_workspace(handle)
|
||||
|
||||
request = {
|
||||
"command": command,
|
||||
"credential_route_refs": credential_route_refs,
|
||||
"execution_context": execution_context,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"max_output_bytes": max_output_bytes,
|
||||
}
|
||||
response_limit = max_output_bytes * 2 + 65_536
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
||||
client.settimeout(timeout_seconds + 5)
|
||||
client.connect(str(workspace / self.control_socket_name))
|
||||
client.sendall(json.dumps(request).encode("utf-8"))
|
||||
client.shutdown(socket.SHUT_WR)
|
||||
while True:
|
||||
chunk = client.recv(65_536)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
if size > response_limit:
|
||||
raise RuntimeError("bwrap owner response exceeded its declared bound")
|
||||
chunks.append(chunk)
|
||||
response = json.loads(b"".join(chunks))
|
||||
if "boundary_error" in response:
|
||||
raise RuntimeError(f"bwrap owner command boundary failed: {response['boundary_error']}")
|
||||
return response
|
||||
|
||||
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
|
||||
pid_str = handle.get("pid", "")
|
||||
killed = False
|
||||
|
|
@ -113,10 +228,8 @@ class BwrapExtension(SandboxExtension):
|
|||
try:
|
||||
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
try:
|
||||
with suppress(ProcessLookupError, PermissionError):
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pass
|
||||
killed = True
|
||||
|
||||
workspace_dir = handle.get("workspace_dir", "")
|
||||
|
|
|
|||
99
src/sandboxer/extensions/bwrap_runner.py
Normal file
99
src/sandboxer/extensions/bwrap_runner.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"""Minimal command broker launched inside an ext.bwrap namespace."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_MAX_REQUEST_BYTES = 1_048_576
|
||||
|
||||
|
||||
def _bounded_output(value: bytes | None, limit: int) -> tuple[str, bool]:
|
||||
raw = value or b""
|
||||
truncated = len(raw) > limit
|
||||
if truncated:
|
||||
raw = raw[:limit]
|
||||
return raw.decode("utf-8", errors="replace"), truncated
|
||||
|
||||
|
||||
def _run(payload: dict, workspace: Path) -> dict[str, object]:
|
||||
command = payload["command"]
|
||||
timeout_seconds = int(payload["timeout_seconds"])
|
||||
max_output_bytes = int(payload["max_output_bytes"])
|
||||
credential_refs = payload.get("credential_route_refs", [])
|
||||
context = payload.get("execution_context", {})
|
||||
child_env = {
|
||||
"HOME": str(workspace),
|
||||
"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()},
|
||||
}
|
||||
timed_out = False
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=workspace,
|
||||
env=child_env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
try:
|
||||
stdout_raw, stderr_raw = process.communicate(timeout=timeout_seconds)
|
||||
exit_code = process.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
stdout_raw, stderr_raw = process.communicate()
|
||||
exit_code = 124
|
||||
stdout, stdout_truncated = _bounded_output(stdout_raw, max_output_bytes)
|
||||
stderr, stderr_truncated = _bounded_output(stderr_raw, max_output_bytes)
|
||||
return {
|
||||
"exit_code": exit_code,
|
||||
"timed_out": timed_out,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"output_truncated": stdout_truncated or stderr_truncated,
|
||||
"workspace_dir": str(workspace),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
workspace = Path(sys.argv[1]).resolve(strict=True)
|
||||
socket_path = Path(sys.argv[2])
|
||||
socket_path.unlink(missing_ok=True)
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
|
||||
server.bind(str(socket_path))
|
||||
socket_path.chmod(0o600)
|
||||
server.listen(8)
|
||||
while True:
|
||||
connection, _ = server.accept()
|
||||
with connection:
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
while True:
|
||||
chunk = connection.recv(65_536)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
if size > _MAX_REQUEST_BYTES:
|
||||
chunks = []
|
||||
break
|
||||
chunks.append(chunk)
|
||||
try:
|
||||
if not chunks:
|
||||
raise ValueError("empty or oversized execution request")
|
||||
response = _run(json.loads(b"".join(chunks)), workspace)
|
||||
except Exception as exc:
|
||||
response = {"boundary_error": str(exc)}
|
||||
connection.sendall(json.dumps(response).encode("utf-8"))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -25,6 +25,19 @@ class ExtensionBackend(Protocol):
|
|||
|
||||
def teardown(self, handle: dict[str, str]) -> dict[str, str]: ...
|
||||
|
||||
def supports_execution(self) -> bool: ...
|
||||
|
||||
def execute(
|
||||
self,
|
||||
handle: dict[str, str],
|
||||
command: list[str],
|
||||
*,
|
||||
credential_route_refs: list[str],
|
||||
execution_context: dict[str, str],
|
||||
timeout_seconds: int,
|
||||
max_output_bytes: int,
|
||||
) -> dict[str, object]: ...
|
||||
|
||||
|
||||
def extensions_dir() -> Path:
|
||||
return _EXTENSIONS_DIR
|
||||
|
|
@ -71,4 +84,4 @@ def resolve_backend(extension: Extension) -> ExtensionBackend:
|
|||
raise ValueError(f"Invalid handler for {extension.id}: {extension.handler}")
|
||||
module = importlib.import_module(module_path)
|
||||
cls = getattr(module, attr)
|
||||
return cls(extension.config)
|
||||
return cls(extension.config)
|
||||
|
|
|
|||
|
|
@ -147,6 +147,36 @@ class SandboxCreateRequest(BaseModel):
|
|||
ttl: str | None = None
|
||||
|
||||
|
||||
class SandboxExecRequest(BaseModel):
|
||||
"""A command grant bound to the consumer recorded at sandbox creation."""
|
||||
|
||||
command: list[str] = Field(min_length=1, max_length=256)
|
||||
consumer: Consumer
|
||||
credential_route_refs: list[str] = Field(default_factory=list, max_length=32)
|
||||
timeout_seconds: int = Field(default=900, ge=1, le=3600)
|
||||
max_output_bytes: int = Field(default=262_144, ge=1, le=1_048_576)
|
||||
|
||||
|
||||
class SandboxExecResult(BaseModel):
|
||||
sandbox_id: str
|
||||
profile_id: str
|
||||
extension_id: str
|
||||
consumer: Consumer
|
||||
command_name: str
|
||||
exit_code: int
|
||||
timed_out: bool = False
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
output_truncated: bool = False
|
||||
duration_seconds: float
|
||||
workspace_dir: str
|
||||
network_default: Literal["deny", "allow"]
|
||||
network_egress: list[str] = Field(default_factory=list)
|
||||
credential_route_refs: list[str] = Field(default_factory=list)
|
||||
started_at: datetime
|
||||
completed_at: datetime
|
||||
|
||||
|
||||
class Reachability(BaseModel):
|
||||
ssh: str | None = None
|
||||
remote_dir: str | None = None
|
||||
|
|
@ -211,4 +241,4 @@ class SnapshotRecord(BaseModel):
|
|||
consumer: Consumer | None = None
|
||||
name: str | None = None
|
||||
size_bytes: int | None = None
|
||||
created_at: datetime
|
||||
created_at: datetime
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
|
||||
from sandboxer.reachability.enrich import build_reachability_report, enrich_reachability
|
||||
|
||||
__all__ = ["enrich_reachability", "build_reachability_report"]
|
||||
__all__ = ["enrich_reachability", "build_reachability_report"]
|
||||
|
|
|
|||
|
|
@ -55,20 +55,6 @@ def ssh_one_liner(reach: Reachability) -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
def local_exec_hint(reach: Reachability) -> str | None:
|
||||
"""No-SSH-hop exec hint for same-host extensions (e.g. ext.bwrap).
|
||||
|
||||
Consumers exec directly into the sandbox's namespaces via the
|
||||
placeholder process's pid, rather than opening an SSH channel.
|
||||
"""
|
||||
if reach.pid and reach.workspace_dir:
|
||||
return (
|
||||
f"nsenter --target {reach.pid} --mount --pid --net --uts --ipc "
|
||||
f"-- sh -c 'cd {reach.workspace_dir} && exec $SHELL'"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def build_reachability_report(status: SandboxStatus) -> dict[str, Any]:
|
||||
"""Consumer-facing reachability report with ops-bridge pointer."""
|
||||
reach = status.reachability
|
||||
|
|
@ -84,5 +70,11 @@ def build_reachability_report(status: SandboxStatus) -> dict[str, Any]:
|
|||
}
|
||||
if reach:
|
||||
payload["ssh_one_liner"] = ssh_one_liner(reach)
|
||||
payload["local_exec_hint"] = local_exec_hint(reach)
|
||||
return payload
|
||||
if status.extension_id == "ext.bwrap":
|
||||
payload["execution"] = {
|
||||
"mode": "owner-mediated",
|
||||
"cli": f"sandboxer exec {status.sandbox_id} [identity options] -- COMMAND...",
|
||||
"api": f"POST /v1/sandboxes/{status.sandbox_id}/exec",
|
||||
"note": "Direct namespace entry is unsupported; invoke the sand-boxer owner",
|
||||
}
|
||||
return payload
|
||||
|
|
|
|||
|
|
@ -5,7 +5,14 @@ from unittest.mock import patch
|
|||
from fastapi.testclient import TestClient
|
||||
|
||||
from sandboxer.api.app import app
|
||||
from sandboxer.models import ActorType, Consumer, SandboxState, SandboxStatus, SnapshotRecord
|
||||
from sandboxer.models import (
|
||||
ActorType,
|
||||
Consumer,
|
||||
SandboxExecResult,
|
||||
SandboxState,
|
||||
SandboxStatus,
|
||||
SnapshotRecord,
|
||||
)
|
||||
|
||||
|
||||
def test_list_sandboxes_empty() -> None:
|
||||
|
|
@ -148,4 +155,59 @@ def test_expire_sandboxes() -> None:
|
|||
client = TestClient(app)
|
||||
resp = client.post("/v1/sandboxes/expire")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()[0]["action"] == "dry-run"
|
||||
assert resp.json()[0]["action"] == "dry-run"
|
||||
|
||||
|
||||
def test_exec_api_fails_closed_when_owner_token_unconfigured(monkeypatch) -> None:
|
||||
monkeypatch.delenv("SANDBOXER_EXEC_TOKEN", raising=False)
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/v1/sandboxes/abc12345/exec",
|
||||
json={
|
||||
"command": ["true"],
|
||||
"consumer": {"actor": "agt", "project": "glas-harness", "run_id": "run-1"},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
def test_exec_api_requires_bearer_and_returns_execution(monkeypatch) -> None:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
monkeypatch.setenv("SANDBOXER_EXEC_TOKEN", "owner-capability")
|
||||
now = datetime.now(UTC)
|
||||
consumer = Consumer(actor="agt", project="glas-harness", run_id="run-1")
|
||||
result = SandboxExecResult(
|
||||
sandbox_id="abc12345",
|
||||
profile_id="profile.bwrap-local",
|
||||
extension_id="ext.bwrap",
|
||||
consumer=consumer,
|
||||
command_name="true",
|
||||
exit_code=0,
|
||||
duration_seconds=0.1,
|
||||
workspace_dir="/tmp/sandboxer-bwrap/abc12345",
|
||||
network_default="deny",
|
||||
started_at=now,
|
||||
completed_at=now,
|
||||
)
|
||||
payload = {
|
||||
"command": ["true"],
|
||||
"consumer": consumer.model_dump(mode="json"),
|
||||
}
|
||||
with patch("sandboxer.api.app._manager") as mgr:
|
||||
mgr.execute.return_value = result
|
||||
client = TestClient(app)
|
||||
denied = client.post(
|
||||
"/v1/sandboxes/abc12345/exec",
|
||||
json=payload,
|
||||
headers={"Authorization": "Bearer wrong"},
|
||||
)
|
||||
allowed = client.post(
|
||||
"/v1/sandboxes/abc12345/exec",
|
||||
json=payload,
|
||||
headers={"Authorization": "Bearer owner-capability"},
|
||||
)
|
||||
|
||||
assert denied.status_code == 401
|
||||
assert allowed.status_code == 200
|
||||
assert allowed.json()["exit_code"] == 0
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
"""ext.bwrap — local namespace isolation extension."""
|
||||
|
||||
import json
|
||||
import signal
|
||||
import socket
|
||||
import stat
|
||||
import subprocess
|
||||
import threading
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from sandboxer.extensions.base import SandboxExtension
|
||||
from sandboxer.extensions.bwrap import BwrapExtension
|
||||
from sandboxer.models import IsolationSpec, Profile
|
||||
from sandboxer.extensions.bwrap_runner import _run
|
||||
from sandboxer.models import IsolationSpec, Profile, Reachability
|
||||
from sandboxer.reachability.enrich import enrich_reachability
|
||||
|
||||
|
||||
def _profile() -> Profile:
|
||||
|
|
@ -40,7 +48,13 @@ def test_bwrap_argv_unshares_net_and_binds_workspace(tmp_path) -> None:
|
|||
assert "--unshare-net" in argv
|
||||
assert "--unshare-user" in argv
|
||||
assert str(tmp_path) in argv
|
||||
assert argv[-2:] == ["sleep", "infinity"]
|
||||
assert "/run/sandboxer/bwrap_runner.py" in argv
|
||||
assert argv[-4:] == [
|
||||
"/usr/bin/python3",
|
||||
"/run/sandboxer/bwrap_runner.py",
|
||||
str(tmp_path),
|
||||
f"{tmp_path}/.sandboxer-owner.sock",
|
||||
]
|
||||
|
||||
|
||||
def test_existing_ro_binds_filters_missing_paths(tmp_path) -> None:
|
||||
|
|
@ -55,13 +69,17 @@ def test_provision_spawns_bwrap_and_returns_handle(tmp_path) -> None:
|
|||
fake_proc = MagicMock()
|
||||
fake_proc.pid = 12345
|
||||
|
||||
with patch("sandboxer.extensions.bwrap.subprocess.Popen", return_value=fake_proc) as popen:
|
||||
with (
|
||||
patch("sandboxer.extensions.bwrap.subprocess.Popen", return_value=fake_proc) as popen,
|
||||
patch.object(BwrapExtension, "_read_child_pid", return_value=23456),
|
||||
):
|
||||
handle = ext.provision(_profile(), {"sandbox_id": "abc12345"}, "localhost")
|
||||
|
||||
popen.assert_called_once()
|
||||
assert handle["sandbox_id"] == "abc12345"
|
||||
assert handle["host"] == "localhost"
|
||||
assert handle["pid"] == "12345"
|
||||
assert handle["pid"] == "23456"
|
||||
assert handle["supervisor_pid"] == "12345"
|
||||
assert handle["workspace_dir"].endswith("abc12345")
|
||||
|
||||
|
||||
|
|
@ -75,13 +93,17 @@ def test_provision_copies_repo_into_workspace(tmp_path) -> None:
|
|||
fake_proc = MagicMock()
|
||||
fake_proc.pid = 1
|
||||
|
||||
with patch("sandboxer.extensions.bwrap.subprocess.Popen", return_value=fake_proc):
|
||||
with (
|
||||
patch("sandboxer.extensions.bwrap.subprocess.Popen", return_value=fake_proc),
|
||||
patch.object(BwrapExtension, "_read_child_pid", return_value=2),
|
||||
):
|
||||
handle = ext.provision(
|
||||
_profile(), {"sandbox_id": "cafe1234", "repo": str(repo)}, "localhost"
|
||||
)
|
||||
|
||||
assert (base_dir / "cafe1234" / "file.txt").read_text() == "hello"
|
||||
assert handle["workspace_dir"] == str(base_dir / "cafe1234")
|
||||
assert stat.S_IMODE((base_dir / "cafe1234").stat().st_mode) == 0o700
|
||||
|
||||
|
||||
def test_provision_missing_repo_raises(tmp_path) -> None:
|
||||
|
|
@ -100,7 +122,10 @@ def test_wait_ready_checks_process_and_workspace(tmp_path) -> None:
|
|||
ext = BwrapExtension()
|
||||
handle = {"pid": str(1), "workspace_dir": str(workspace), "host": "localhost"}
|
||||
|
||||
with patch.object(BwrapExtension, "_pid_alive", return_value=True):
|
||||
with (
|
||||
patch.object(BwrapExtension, "_pid_alive", return_value=True),
|
||||
patch("sandboxer.extensions.bwrap.Path.is_socket", return_value=True),
|
||||
):
|
||||
result = ext.wait_ready(handle)
|
||||
|
||||
assert result["host"] == "localhost"
|
||||
|
|
@ -113,18 +138,22 @@ def test_wait_ready_raises_if_process_dead(tmp_path) -> None:
|
|||
ext = BwrapExtension()
|
||||
handle = {"pid": "999999", "workspace_dir": str(workspace), "host": "localhost"}
|
||||
|
||||
with patch.object(BwrapExtension, "_pid_alive", return_value=False):
|
||||
with pytest.raises(RuntimeError, match="not running"):
|
||||
ext.wait_ready(handle)
|
||||
with (
|
||||
patch.object(BwrapExtension, "_pid_alive", return_value=False),
|
||||
pytest.raises(RuntimeError, match="not running"),
|
||||
):
|
||||
ext.wait_ready(handle)
|
||||
|
||||
|
||||
def test_wait_ready_raises_if_workspace_missing(tmp_path) -> None:
|
||||
ext = BwrapExtension()
|
||||
handle = {"pid": "1", "workspace_dir": str(tmp_path / "gone"), "host": "localhost"}
|
||||
|
||||
with patch.object(BwrapExtension, "_pid_alive", return_value=True):
|
||||
with pytest.raises(RuntimeError, match="workspace missing"):
|
||||
ext.wait_ready(handle)
|
||||
with (
|
||||
patch.object(BwrapExtension, "_pid_alive", return_value=True),
|
||||
pytest.raises(RuntimeError, match="workspace missing"),
|
||||
):
|
||||
ext.wait_ready(handle)
|
||||
|
||||
|
||||
def test_teardown_kills_process_group_and_removes_workspace(tmp_path) -> None:
|
||||
|
|
@ -166,10 +195,7 @@ def test_supports_snapshots_is_false() -> None:
|
|||
ext.snapshot({})
|
||||
|
||||
|
||||
def test_reachability_local_exec_hint_for_bwrap_handle() -> None:
|
||||
from sandboxer.reachability.enrich import enrich_reachability, local_exec_hint
|
||||
from sandboxer.models import Reachability
|
||||
|
||||
def test_reachability_enriches_bwrap_handle_without_direct_exec_hint() -> None:
|
||||
handle = {"pid": "555", "workspace_dir": "/tmp/sandboxer-bwrap/abc", "host": "localhost"}
|
||||
reach = {"host": "localhost", "endpoint": "pid:555"}
|
||||
profile = _profile()
|
||||
|
|
@ -179,7 +205,147 @@ def test_reachability_local_exec_hint_for_bwrap_handle() -> None:
|
|||
|
||||
assert reachability.pid == "555"
|
||||
assert reachability.workspace_dir == "/tmp/sandboxer-bwrap/abc"
|
||||
assert local_exec_hint(reachability) == (
|
||||
"nsenter --target 555 --mount --pid --net --uts --ipc "
|
||||
"-- sh -c 'cd /tmp/sandboxer-bwrap/abc && exec $SHELL'"
|
||||
)
|
||||
|
||||
|
||||
def test_execute_sends_bounded_request_to_in_namespace_owner(tmp_path) -> None:
|
||||
base_dir = tmp_path / "sandboxes"
|
||||
workspace = base_dir / "abc12345"
|
||||
workspace.mkdir(parents=True)
|
||||
ext = BwrapExtension({"base_dir": str(base_dir)})
|
||||
handle = {
|
||||
"sandbox_id": "abc12345",
|
||||
"pid": "42",
|
||||
"workspace_dir": str(workspace),
|
||||
}
|
||||
control = workspace / ".sandboxer-owner.sock"
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(str(control))
|
||||
server.listen(1)
|
||||
received = {}
|
||||
|
||||
def serve_once() -> None:
|
||||
connection, _ = server.accept()
|
||||
with connection:
|
||||
raw = b""
|
||||
while chunk := connection.recv(65_536):
|
||||
raw += chunk
|
||||
received.update(json.loads(raw))
|
||||
connection.sendall(
|
||||
json.dumps(
|
||||
{
|
||||
"exit_code": 0,
|
||||
"timed_out": False,
|
||||
"stdout": "ok\n",
|
||||
"stderr": "",
|
||||
"output_truncated": False,
|
||||
"workspace_dir": str(workspace),
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
|
||||
thread = threading.Thread(target=serve_once)
|
||||
thread.start()
|
||||
|
||||
with patch.object(BwrapExtension, "_pid_alive", return_value=True):
|
||||
result = ext.execute(
|
||||
handle,
|
||||
["python3", "-V"],
|
||||
credential_route_refs=["rein-openweights-openrouter-approle"],
|
||||
execution_context={"actor": "agt", "run_id": "run-1"},
|
||||
timeout_seconds=30,
|
||||
max_output_bytes=1024,
|
||||
)
|
||||
thread.join(timeout=2)
|
||||
server.close()
|
||||
|
||||
assert received["command"] == ["python3", "-V"]
|
||||
assert received["execution_context"] == {"actor": "agt", "run_id": "run-1"}
|
||||
assert received["credential_route_refs"] == ["rein-openweights-openrouter-approle"]
|
||||
assert received["timeout_seconds"] == 30
|
||||
assert received["max_output_bytes"] == 1024
|
||||
assert result["stdout"] == "ok\n"
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
|
||||
def test_in_namespace_runner_uses_sanitized_environment(tmp_path) -> None:
|
||||
process = MagicMock()
|
||||
process.returncode = 0
|
||||
process.communicate.return_value = (b"ok\n", b"")
|
||||
payload = {
|
||||
"command": ["python3", "-V"],
|
||||
"credential_route_refs": ["rein-openweights-openrouter-approle"],
|
||||
"execution_context": {"actor": "agt", "run_id": "run-1"},
|
||||
"timeout_seconds": 30,
|
||||
"max_output_bytes": 1024,
|
||||
}
|
||||
with patch(
|
||||
"sandboxer.extensions.bwrap_runner.subprocess.Popen", return_value=process
|
||||
) as popen:
|
||||
result = _run(payload, tmp_path)
|
||||
|
||||
assert popen.call_args.args[0] == ["python3", "-V"]
|
||||
assert popen.call_args.kwargs["cwd"] == tmp_path
|
||||
child_env = popen.call_args.kwargs["env"]
|
||||
assert child_env["SANDBOXER_ACTOR"] == "agt"
|
||||
assert child_env["SANDBOXER_RUN_ID"] == "run-1"
|
||||
assert "rein-openweights-openrouter-approle" in child_env[
|
||||
"SANDBOXER_CREDENTIAL_ROUTE_REFS"
|
||||
]
|
||||
assert set(child_env) == {
|
||||
"HOME",
|
||||
"LANG",
|
||||
"PATH",
|
||||
"SANDBOXER_CREDENTIAL_ROUTE_REFS",
|
||||
"SANDBOXER_ACTOR",
|
||||
"SANDBOXER_RUN_ID",
|
||||
}
|
||||
assert result["stdout"] == "ok\n"
|
||||
|
||||
|
||||
def test_execute_refuses_workspace_outside_owner_base(tmp_path) -> None:
|
||||
outside = tmp_path / "source-checkout"
|
||||
outside.mkdir()
|
||||
ext = BwrapExtension({"base_dir": str(tmp_path / "sandboxes")})
|
||||
handle = {"sandbox_id": "abc12345", "pid": "42", "workspace_dir": str(outside)}
|
||||
|
||||
with (
|
||||
patch.object(BwrapExtension, "_pid_alive", return_value=True),
|
||||
pytest.raises(RuntimeError, match="outside owner-managed"),
|
||||
):
|
||||
ext.execute(
|
||||
handle,
|
||||
["true"],
|
||||
credential_route_refs=[],
|
||||
execution_context={},
|
||||
timeout_seconds=30,
|
||||
max_output_bytes=1024,
|
||||
)
|
||||
|
||||
|
||||
def test_runner_bounds_output_and_normalizes_timeout(tmp_path) -> None:
|
||||
process = MagicMock()
|
||||
process.pid = 99
|
||||
process.communicate.side_effect = [
|
||||
subprocess.TimeoutExpired("cmd", 1),
|
||||
(b"123456", b"abcdef"),
|
||||
]
|
||||
payload = {
|
||||
"command": ["sleep", "2"],
|
||||
"credential_route_refs": [],
|
||||
"execution_context": {},
|
||||
"timeout_seconds": 1,
|
||||
"max_output_bytes": 3,
|
||||
}
|
||||
|
||||
with (
|
||||
patch("sandboxer.extensions.bwrap_runner.subprocess.Popen", return_value=process),
|
||||
patch("sandboxer.extensions.bwrap_runner.os.killpg") as killpg,
|
||||
):
|
||||
result = _run(payload, tmp_path)
|
||||
|
||||
killpg.assert_called_once_with(99, signal.SIGKILL)
|
||||
assert result["exit_code"] == 124
|
||||
assert result["timed_out"] is True
|
||||
assert result["stdout"] == "123"
|
||||
assert result["stderr"] == "abc"
|
||||
assert result["output_truncated"] is True
|
||||
|
|
|
|||
|
|
@ -2,15 +2,23 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from sandboxer.core.manager import SandboxManager
|
||||
from sandboxer.lifecycle.store import SandboxStore
|
||||
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest, SandboxState, SandboxStatus
|
||||
from sandboxer.models import (
|
||||
ActorType,
|
||||
Consumer,
|
||||
SandboxCreateRequest,
|
||||
SandboxExecRequest,
|
||||
SandboxState,
|
||||
SandboxStatus,
|
||||
)
|
||||
|
||||
|
||||
class FakeBackend:
|
||||
|
|
@ -40,6 +48,26 @@ class FakeBackend:
|
|||
}
|
||||
|
||||
|
||||
class FakeExecBackend:
|
||||
def supports_execution(self):
|
||||
return True
|
||||
|
||||
def execute(self, handle, command, **kwargs):
|
||||
return {
|
||||
"exit_code": 0,
|
||||
"timed_out": False,
|
||||
"stdout": "inside\n",
|
||||
"stderr": "",
|
||||
"output_truncated": False,
|
||||
"workspace_dir": handle["workspace_dir"],
|
||||
}
|
||||
|
||||
|
||||
class FakeNonExecBackend:
|
||||
def supports_execution(self):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path: Path) -> SandboxStore:
|
||||
return SandboxStore(path=tmp_path / "sandboxes.json")
|
||||
|
|
@ -82,4 +110,133 @@ def test_destroy_idempotent(store: SandboxStore) -> None:
|
|||
store.save(status)
|
||||
manager = SandboxManager(store=store)
|
||||
result = manager.destroy("gone1234")
|
||||
assert result.state == SandboxState.DESTROYED
|
||||
assert result.state == SandboxState.DESTROYED
|
||||
|
||||
|
||||
def _ready_bwrap_status(*, state: SandboxState = SandboxState.READY) -> SandboxStatus:
|
||||
now = datetime.now(UTC)
|
||||
return SandboxStatus(
|
||||
sandbox_id="bwrap123",
|
||||
profile_id="profile.bwrap-local",
|
||||
extension_id="ext.bwrap",
|
||||
state=state,
|
||||
consumer=Consumer(
|
||||
actor=ActorType.AGT,
|
||||
project="glas-harness",
|
||||
session_id="session-1",
|
||||
run_id="run-1",
|
||||
),
|
||||
host="localhost",
|
||||
inputs={"pid": "42", "workspace_dir": "/tmp/sandboxer-bwrap/bwrap123"},
|
||||
expires_at=now + timedelta(hours=1),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
ready_at=now,
|
||||
)
|
||||
|
||||
|
||||
def _exec_request(**consumer_overrides) -> SandboxExecRequest:
|
||||
consumer = {
|
||||
"actor": "agt",
|
||||
"project": "glas-harness",
|
||||
"session_id": "session-1",
|
||||
"run_id": "run-1",
|
||||
**consumer_overrides,
|
||||
}
|
||||
return SandboxExecRequest(
|
||||
command=["python3", "-V"],
|
||||
consumer=Consumer.model_validate(consumer),
|
||||
credential_route_refs=["rein-openweights-openrouter-approle"],
|
||||
timeout_seconds=30,
|
||||
)
|
||||
|
||||
|
||||
def test_execute_binds_identity_and_restores_ready_state(store: SandboxStore) -> None:
|
||||
store.save(_ready_bwrap_status())
|
||||
manager = SandboxManager(store=store)
|
||||
backend = FakeExecBackend()
|
||||
|
||||
with (
|
||||
patch("sandboxer.core.manager.resolve_backend", return_value=backend),
|
||||
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
|
||||
):
|
||||
result = manager.execute("bwrap123", _exec_request())
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout == "inside\n"
|
||||
assert result.network_default == "deny"
|
||||
assert result.network_egress == []
|
||||
assert result.credential_route_refs == ["rein-openweights-openrouter-approle"]
|
||||
assert store.get("bwrap123").state == SandboxState.READY
|
||||
|
||||
|
||||
def test_execute_refuses_identity_mismatch(store: SandboxStore) -> None:
|
||||
store.save(_ready_bwrap_status())
|
||||
manager = SandboxManager(store=store)
|
||||
|
||||
with pytest.raises(PermissionError, match="exactly match"):
|
||||
manager.execute("bwrap123", _exec_request(run_id="another-run"))
|
||||
|
||||
|
||||
def test_execute_refuses_non_ready_and_expired_sandboxes(store: SandboxStore) -> None:
|
||||
active = _ready_bwrap_status(state=SandboxState.ACTIVE)
|
||||
store.save(active)
|
||||
manager = SandboxManager(store=store)
|
||||
with pytest.raises(RuntimeError, match="must be ready"):
|
||||
manager.execute("bwrap123", _exec_request())
|
||||
|
||||
|
||||
def test_execute_refuses_concurrent_command(store: SandboxStore) -> None:
|
||||
store.save(_ready_bwrap_status())
|
||||
manager = SandboxManager(store=store)
|
||||
lock = manager._exec_locks.setdefault("bwrap123", Lock())
|
||||
lock.acquire()
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="already has an active"):
|
||||
manager.execute("bwrap123", _exec_request())
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
expired = _ready_bwrap_status()
|
||||
expired.expires_at = datetime.now(UTC) - timedelta(seconds=1)
|
||||
store.save(expired)
|
||||
with pytest.raises(RuntimeError, match="TTL has expired"):
|
||||
manager.execute("bwrap123", _exec_request())
|
||||
|
||||
|
||||
def test_execute_refuses_invalid_credential_route_ref(store: SandboxStore) -> None:
|
||||
store.save(_ready_bwrap_status())
|
||||
request = _exec_request()
|
||||
request.credential_route_refs = ["looks like a secret value"]
|
||||
|
||||
with pytest.raises(ValueError, match="value-free catalog"):
|
||||
SandboxManager(store=store).execute("bwrap123", request)
|
||||
|
||||
|
||||
def test_execute_refuses_extension_without_owner_boundary(store: SandboxStore) -> None:
|
||||
status = _ready_bwrap_status()
|
||||
status.profile_id = "profile.compose-e2e"
|
||||
status.extension_id = "ext.compose-ssh"
|
||||
store.save(status)
|
||||
|
||||
with (
|
||||
patch("sandboxer.core.manager.resolve_backend", return_value=FakeNonExecBackend()),
|
||||
pytest.raises(RuntimeError, match="no owner-mediated execution boundary"),
|
||||
):
|
||||
SandboxManager(store=store).execute("bwrap123", _exec_request())
|
||||
|
||||
|
||||
def test_execute_restores_ready_after_backend_failure(store: SandboxStore) -> None:
|
||||
store.save(_ready_bwrap_status())
|
||||
manager = SandboxManager(store=store)
|
||||
backend = FakeExecBackend()
|
||||
backend.execute = lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom"))
|
||||
|
||||
with (
|
||||
patch("sandboxer.core.manager.resolve_backend", return_value=backend),
|
||||
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
|
||||
pytest.raises(RuntimeError, match="boom"),
|
||||
):
|
||||
manager.execute("bwrap123", _exec_request())
|
||||
|
||||
assert store.get("bwrap123").state == SandboxState.READY
|
||||
|
|
|
|||
|
|
@ -75,4 +75,30 @@ def test_build_reachability_report() -> None:
|
|||
report = build_reachability_report(status)
|
||||
assert report["sandbox_id"] == "abc12345"
|
||||
assert report["ssh_one_liner"] is not None
|
||||
assert "ops_bridge" in report
|
||||
assert "ops_bridge" in report
|
||||
|
||||
|
||||
def test_bwrap_report_points_to_owner_execution_not_nsenter() -> None:
|
||||
now = datetime.now(UTC)
|
||||
status = SandboxStatus(
|
||||
sandbox_id="bwrap123",
|
||||
profile_id="profile.bwrap-local",
|
||||
extension_id="ext.bwrap",
|
||||
state=SandboxState.READY,
|
||||
consumer=Consumer(actor=ActorType.AGT, project="glas-harness", run_id="run-1"),
|
||||
host="localhost",
|
||||
reachability=Reachability(
|
||||
host="localhost",
|
||||
endpoint="pid:42",
|
||||
pid="42",
|
||||
workspace_dir="/tmp/sandboxer-bwrap/bwrap123",
|
||||
),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
report = build_reachability_report(status)
|
||||
|
||||
assert report["execution"]["mode"] == "owner-mediated"
|
||||
assert "nsenter" not in str(report)
|
||||
assert "local_exec_hint" not in report
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ def test_resolve_initial_ttl() -> None:
|
|||
|
||||
|
||||
def test_extend_expires_at_caps_at_max() -> None:
|
||||
anchor = datetime(2026, 6, 24, 10, 0, tzinfo=UTC)
|
||||
anchor = datetime.now(UTC)
|
||||
current = anchor + timedelta(hours=23)
|
||||
new_expires, applied = extend_expires_at(
|
||||
current,
|
||||
|
|
@ -262,4 +262,4 @@ def test_manager_expire_dry_run_and_apply(store: SandboxStore) -> None:
|
|||
applied = manager.expire(apply=True, now=now)
|
||||
|
||||
assert applied[0].action == "destroyed"
|
||||
assert manager.get("gone5678").state == SandboxState.DESTROYED
|
||||
assert manager.get("gone5678").state == SandboxState.DESTROYED
|
||||
|
|
|
|||
121
workplans/SAND-WP-0014-owner-mediated-execution.md
Normal file
121
workplans/SAND-WP-0014-owner-mediated-execution.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
---
|
||||
id: SAND-WP-0014
|
||||
type: workplan
|
||||
title: "Owner-mediated governed bwrap execution"
|
||||
domain: infotech
|
||||
repo: sand-boxer
|
||||
status: active
|
||||
owner: codex
|
||||
topic_slug: owner-mediated-execution
|
||||
created: "2026-09-04"
|
||||
updated: "2026-09-04"
|
||||
state_hub_workstream_id: "b616d1cd-208f-5ecf-a4a0-a028396422c4"
|
||||
---
|
||||
|
||||
# Owner-mediated governed bwrap execution
|
||||
|
||||
Promote intake `GLAS-IN-0002` into sand-boxer ownership. Replace the unusable
|
||||
consumer-side `nsenter` hint with an owner-executed command boundary that remains
|
||||
bound to the sandbox and its governed consumer identity. A refused execution
|
||||
must never fall back to a host checkout or another extension.
|
||||
|
||||
## Define the governed execution contract
|
||||
|
||||
```task
|
||||
id: SAND-WP-0014-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "1b4349de-7027-5f01-8deb-122772e1404d"
|
||||
```
|
||||
|
||||
Define request/result schemas and evidence for exact actor/project/session/run
|
||||
binding, ready/active lifecycle, workspace confinement, declared network
|
||||
posture, value-free credential route references, bounded command duration, and
|
||||
bounded output.
|
||||
|
||||
## Implement owner-mediated bwrap execution
|
||||
|
||||
```task
|
||||
id: SAND-WP-0014-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "59f9af7c-7cd7-5e86-849f-5dfc7a3a06ee"
|
||||
```
|
||||
|
||||
Add manager, extension, CLI, and authenticated HTTP surfaces. Only `ext.bwrap`
|
||||
may execute in this slice. The extension launches an owner broker inside bwrap,
|
||||
uses the sandbox workspace as its working directory, starts commands with a sanitized
|
||||
environment, and fails closed if the recorded workspace is not the exact
|
||||
owner-managed sandbox directory.
|
||||
|
||||
## Remove direct namespace-entry guidance
|
||||
|
||||
```task
|
||||
id: SAND-WP-0014-T03
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "4fc7d747-13d5-58e7-b89e-b72e845515aa"
|
||||
```
|
||||
|
||||
Stop publishing raw consumer-side `nsenter` commands. Reachability describes
|
||||
owner-mediated execution without exposing a misleading direct-attach route.
|
||||
|
||||
## Verify unit and local namespace behavior
|
||||
|
||||
```task
|
||||
id: SAND-WP-0014-T04
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "c7ada937-4430-59d7-b3a6-9f3f36e9a907"
|
||||
```
|
||||
|
||||
Cover identity/state/TTL refusal, unsupported-extension refusal, exact workspace
|
||||
validation, sanitized environment, credential route references, timeout/output
|
||||
bounds, API authentication, lifecycle restoration, and teardown. Run the full
|
||||
repository check and, when host user namespaces permit it, a non-secret live
|
||||
bwrap command proof.
|
||||
|
||||
Implemented and verified 2026-09-04. `make check` passes with 119 tests. The
|
||||
first live attempt proved that even the bwrap owner cannot reliably `setns`
|
||||
from the host on this kernel (`IPC: Operation not permitted`), so execution was
|
||||
moved to a small read-only broker launched inside the namespace and reached via
|
||||
an owner-only Unix socket. `make smoke-bwrap-exec` then passed: actor `agt`,
|
||||
project/session/run binding reached the command; cwd was the copied sandbox;
|
||||
the host-source sentinel was absent; the command created an artifact; the net
|
||||
namespace exposed only `lo` under declared `default: deny`, `egress: []`; no
|
||||
credential routes or values were present; and teardown reported `destroyed`
|
||||
with the workspace removed.
|
||||
|
||||
## Prove one governed rein and coordinate consumers
|
||||
|
||||
```task
|
||||
id: SAND-WP-0014-T05
|
||||
status: wait
|
||||
priority: high
|
||||
state_hub_task_id: "c6812fd4-bb7b-5be0-8344-a122496fe5bc"
|
||||
```
|
||||
|
||||
With Glas and rein-aharness, run one real selected rein command through the
|
||||
owner API using an explicitly declared egress profile and catalog credential
|
||||
route. Prove that the source checkout is absent, retain value-free evidence,
|
||||
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.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Execution requires the recorded actor, project, session, and run identity.
|
||||
- Only a live, unexpired, ready sandbox can start a command; concurrent commands
|
||||
are refused.
|
||||
- Command execution is argument-vector based, starts inside the copied sandbox
|
||||
workspace, and has no host/source-checkout fallback.
|
||||
- `ext.bwrap` supplies its declared read-only runtime paths and enforces the
|
||||
profile's network namespace. Evidence reports default policy and egress list.
|
||||
- Credential inputs are non-secret catalog route references only.
|
||||
- Duration and captured output are bounded, lifecycle state is restored, and
|
||||
teardown remains idempotent.
|
||||
- Consumer-facing reachability no longer recommends direct `nsenter`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue