Compare commits
2 commits
c2886b2f77
...
d477c3b5d9
| Author | SHA1 | Date | |
|---|---|---|---|
| d477c3b5d9 | |||
| d69827aaa2 |
18 changed files with 1192 additions and 31 deletions
|
|
@ -22,6 +22,7 @@
|
||||||
| workplan | SAND-WP-0012 | finished | — | workplans/SAND-WP-0012-packer-orchestration.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-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-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-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-T02 | done | — | workplans/SAND-WP-0001-statehub-bootstrap.md |
|
||||||
| task | SAND-WP-0001-T03 | 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-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-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-0014-T05 | wait | — | workplans/SAND-WP-0014-owner-mediated-execution.md |
|
||||||
|
| task | SAND-WP-0015-T01 | done | — | workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md |
|
||||||
|
| task | SAND-WP-0015-T02 | done | — | workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md |
|
||||||
|
| task | SAND-WP-0015-T03 | done | — | workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md |
|
||||||
|
| task | SAND-WP-0015-T04 | wait | — | workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md |
|
||||||
|
|
|
||||||
39
docs/bwrap-egress.md
Normal file
39
docs/bwrap-egress.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Bwrap HTTPS egress
|
||||||
|
|
||||||
|
Owner extension config can set `allowed_egress: [api.anthropic.com:443]`.
|
||||||
|
A profile must declare a subset in `network.egress`, with `default: deny`.
|
||||||
|
Defaults remain empty. Only exact lowercase DNS names ending in `:443`
|
||||||
|
are accepted; no wildcards, IP literals, URLs or other ports.
|
||||||
|
|
||||||
|
Each opted-in sandbox gets a separate host-side Unix CONNECT proxy. Only that
|
||||||
|
socket is mounted into the sandbox; an in-namespace loopback bridge provides
|
||||||
|
HTTPS_PROXY/https_proxy to child processes. The namespace retains only lo,
|
||||||
|
without a host interface or direct DNS/network access. The owner proxy resolves
|
||||||
|
the declared hostname, rejects any non-global result, and connects to a checked
|
||||||
|
numeric address without a second lookup. TLS stays between client and provider;
|
||||||
|
system CA certificates are mounted read-only. No TLS interception or body logging.
|
||||||
|
|
||||||
|
This enforces connection destinations, not HTTP paths, provider account identity,
|
||||||
|
or TLS SNI on shared hosting. A client can send arbitrary bytes to an allowed
|
||||||
|
server. It is not an application firewall or a defense against an allowed
|
||||||
|
provider's own forwarding features. A future stronger policy needs separate
|
||||||
|
application enforcement. DNS resolution uses the trusted host resolver.
|
||||||
|
|
||||||
|
Up to 16 concurrent tunnels per sandbox, 8 KiB CONNECT headers, 10-second
|
||||||
|
socket operations, 30-second idle and 900-second tunnel lifetime. Unsupported
|
||||||
|
methods, hosts, ports and request framing close without upstream dialing.
|
||||||
|
Teardown kills the dedicated proxy process and removes its socket directory.
|
||||||
|
The current host owner must remain trusted; no caller-selected proxy config.
|
||||||
|
|
||||||
|
Validation: `scripts/smoke-bwrap-egress.py` made a credential-free TLS GET to
|
||||||
|
api.anthropic.com from sandbox e290e788 and received HTTP 404. Undeclared
|
||||||
|
example.com and direct 1.1.1.1:443 were denied, only lo existed, and proxy plus
|
||||||
|
workspace teardown passed. This is transport evidence, not model authentication.
|
||||||
|
Unit tests cover invalid destinations, private and mapped-loopback DNS,
|
||||||
|
checked-address dialing, CONNECT framing and owner/profile allowlist separation.
|
||||||
|
|
||||||
|
Claude documents HTTPS_PROXY at https://code.claude.com/docs/en/network-config.
|
||||||
|
The pinned Claude executable must still pass its own proxy/startup compatibility
|
||||||
|
proof. The existing production profiles are unchanged. CCR-2026-0016 custody is
|
||||||
|
complete at version 2, but owner machine authentication and protected credential
|
||||||
|
delivery remain outstanding under SAND-WP-0015-T04 and GLAS-WP-0012-T02.
|
||||||
124
docs/bwrap-runtime.md
Normal file
124
docs/bwrap-runtime.md
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
# Bwrap runtime and private state
|
||||||
|
|
||||||
|
`ext.bwrap` can mount a standalone Python runtime selected by the owner's
|
||||||
|
extension configuration. A create/exec caller cannot select host paths or
|
||||||
|
replace the sanitized child environment.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
config:
|
||||||
|
runtime:
|
||||||
|
path: /srv/sandboxer/runtimes/rein-aharness-example
|
||||||
|
sha256: <reviewed-complete-artifact-digest>
|
||||||
|
```
|
||||||
|
|
||||||
|
The path must be a canonical absolute directory with `pyvenv.cfg` and
|
||||||
|
`bin/python3`. Its complete tree digest includes file contents, modes, directory
|
||||||
|
entries, and internal symlinks. Absolute/escaping symlinks and special files
|
||||||
|
are refused. Missing files, added files, changed permissions, or changed
|
||||||
|
content invalidate the pin. The runtime must not overlap the source checkout
|
||||||
|
or writable sandbox workspace.
|
||||||
|
|
||||||
|
The owner mounts the artifact read-only at `/opt/sandboxer/runtime` and prepends
|
||||||
|
its `bin` directory to the child PATH. Console entrypoints must use that fixed
|
||||||
|
mount prefix. Artifacts remain an owner trust boundary: publish them in a
|
||||||
|
protected location and do not mutate them while sandboxes use them. Digest
|
||||||
|
validation occurs at creation; it is not a mechanism for making a mutable host
|
||||||
|
directory immutable to its owner.
|
||||||
|
|
||||||
|
## Building and testing the Python rein bundle
|
||||||
|
|
||||||
|
From this repository, with committed rein/llm-connect source checkouts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python scripts/build-rein-runtime.py \
|
||||||
|
--output /tmp/rein-runtime-candidate \
|
||||||
|
--rein-source ../rein-aharness \
|
||||||
|
--llm-source ../llm-connect
|
||||||
|
```
|
||||||
|
|
||||||
|
The builder creates a standalone system-Python venv, installs both packages
|
||||||
|
non-editably with their dependencies, rewrites Python console entrypoint
|
||||||
|
shebangs to the fixed mount path, and records source revisions plus resolved
|
||||||
|
package versions in `build-info.json`. It prints the resulting path/digest.
|
||||||
|
Dependency versions are resolved during this candidate build; the digest pins
|
||||||
|
the produced artifact, not a promise that another build will be bit-identical.
|
||||||
|
No interactive home, credentials, or provider login is copied into the bundle.
|
||||||
|
|
||||||
|
Prove the artifact using the returned values:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python scripts/smoke-bwrap-runtime.py \
|
||||||
|
--runtime-path /tmp/rein-runtime-candidate \
|
||||||
|
--runtime-sha256 <returned-digest>
|
||||||
|
```
|
||||||
|
|
||||||
|
The smoke uses an isolated owner extension configuration and the real broker.
|
||||||
|
It imports the rein's Claude adapter and llm-connect, runs `rein-aharness
|
||||||
|
--help`, proves the runtime rejects writes, writes private state outside the
|
||||||
|
worktree, verifies source absence and a clean Git tree, checks loopback-only
|
||||||
|
networking, and tears down. This is real CLI startup, not a model task.
|
||||||
|
|
||||||
|
The Python bundle does not package or pin the separate Claude executable.
|
||||||
|
That executable's release, startup behavior, and authentication still require
|
||||||
|
review in the eventual production runtime. No committed profile selects a
|
||||||
|
temporary `/tmp` build as its production runtime.
|
||||||
|
|
||||||
|
## Private writable state
|
||||||
|
|
||||||
|
The broker creates mode-0700 directories in the namespace's temporary root:
|
||||||
|
|
||||||
|
| Environment variable | Location |
|
||||||
|
|---|---|
|
||||||
|
| `HOME` | `/run/sandboxer/state/home` |
|
||||||
|
| `XDG_CONFIG_HOME` | `/run/sandboxer/state/config` |
|
||||||
|
| `XDG_CACHE_HOME` | `/run/sandboxer/state/cache` |
|
||||||
|
| `XDG_STATE_HOME` | `/run/sandboxer/state/data` |
|
||||||
|
| `TMPDIR` | `/run/sandboxer/state/tmp` |
|
||||||
|
|
||||||
|
These directories persist between exec requests in the same sandbox, remain
|
||||||
|
outside the Git worktree, and disappear with namespace teardown. Python user
|
||||||
|
site packages and bytecode writes are disabled. The owner's child environment
|
||||||
|
continues to carry exact consumer identity and value-free route references.
|
||||||
|
|
||||||
|
## Network and credentials remain separate gates
|
||||||
|
|
||||||
|
Bwrap refuses `network.default: allow` and setup secret references. Network
|
||||||
|
egress is opt-in through the owner allowlist described in bwrap-egress.md;
|
||||||
|
empty-egress profiles retain loopback-only networking. There is still no
|
||||||
|
credential-delivery mechanism. Returning a
|
||||||
|
declared egress list as evidence would not make that list enforced or usable.
|
||||||
|
Exec credential route references continue to be labels, not credential values
|
||||||
|
or delivery grants.
|
||||||
|
|
||||||
|
On 2026-09-05, `warden route find anthropic --json` and `warden route find
|
||||||
|
claude-code --json` returned no matching workload routes. The generic OpenBao
|
||||||
|
entry is explicitly a routing template, not an executable credential lane.
|
||||||
|
The OpenRouter AppRole route belongs to the other rein and is not a substitute
|
||||||
|
for Claude authentication.
|
||||||
|
|
||||||
|
`SAND-WP-0015-T04` / `SAND-WP-0014-T05` and `GLAS-WP-0012-T02` retain the
|
||||||
|
production requirements: a concrete owner-approved Claude credential route,
|
||||||
|
delivery and revocation semantics, a pinned Claude runtime, and explicitly
|
||||||
|
enforced provider egress with negative tests. Glas local profiles remain
|
||||||
|
blocked until those requirements and the real-model acceptance pass.
|
||||||
|
|
||||||
|
## 2026-09-05 candidate evidence
|
||||||
|
|
||||||
|
Runtime SHA-256:
|
||||||
|
`4c316737ec2715936a12c4f49621a5e4be3d1f4fe4739130393f28cdda66fbd9`.
|
||||||
|
Candidate path: `/tmp/sandboxer-rein-aharness-runtime-20260905-v2`.
|
||||||
|
Source revisions: rein-aharness `1429db5ad4c83331b6375349ffde1eb13af9575b`,
|
||||||
|
llm-connect `00560945f81ba6ff1f5cacd9fe99c7fe756cc4b1`; Python 3.12.3.
|
||||||
|
|
||||||
|
Sandbox `f333fb66` passed the real CLI/import/read-only/private-state smoke.
|
||||||
|
`sys.prefix` was `/opt/sandboxer/runtime`, HOME was outside the worktree with
|
||||||
|
mode 0700, Git stayed clean, source was absent, only `lo` existed, credential
|
||||||
|
references were empty, and the workspace was removed. No model request or
|
||||||
|
credential acquisition occurred.
|
||||||
|
|
||||||
|
Final validation: `make check` passed lint and 132 tests. Authenticated owner
|
||||||
|
API smoke `223db65b` returned HTTP 200 with exact identity/stdin and complete
|
||||||
|
teardown. Follow-up runtime smoke `d4de9531` repeated the real CLI/startup
|
||||||
|
checks and proved private state survives a second exec in the same namespace.
|
||||||
|
The default unconfigured `profile.bwrap-local` still has no selected rein
|
||||||
|
bundle; candidate startup does not constitute production deployment.
|
||||||
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())
|
||||||
70
scripts/smoke-bwrap-egress-manager.py
Normal file
70
scripts/smoke-bwrap-egress-manager.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sandboxer.core.manager import SandboxManager
|
||||||
|
from sandboxer.extensions.bwrap import BwrapExtension
|
||||||
|
from sandboxer.lifecycle.store import SandboxStore
|
||||||
|
from sandboxer.models import Extension, Profile, SandboxCreateRequest, SandboxExecRequest
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"proof", Path(__file__).with_name("smoke-bwrap-egress.py")
|
||||||
|
)
|
||||||
|
proof = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(proof)
|
||||||
|
os.environ["SANDBOXER_NO_STATE_HUB"] = "1"
|
||||||
|
with tempfile.TemporaryDirectory(prefix="glas-managed-egress-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
profile = Profile(
|
||||||
|
id="profile.egress-proof",
|
||||||
|
version="1",
|
||||||
|
extension="ext.bwrap",
|
||||||
|
network={"default": "deny", "egress": ["api.anthropic.com:443"]},
|
||||||
|
)
|
||||||
|
ext = Extension(
|
||||||
|
id="ext.bwrap",
|
||||||
|
title="proof",
|
||||||
|
handler="sandboxer.extensions.bwrap:BwrapExtension",
|
||||||
|
config={"base_dir": str(root / "sandboxes"), "allowed_egress": ["api.anthropic.com:443"]},
|
||||||
|
)
|
||||||
|
consumer = {"actor": "agt", "project": "glas-harness", "run_id": "egress-proof"}
|
||||||
|
with (
|
||||||
|
patch("sandboxer.core.manager.load_profile", return_value=profile),
|
||||||
|
patch("sandboxer.core.manager.resolve_extension", return_value=ext),
|
||||||
|
patch("sandboxer.core.manager.load_extension", return_value=ext),
|
||||||
|
):
|
||||||
|
store = SandboxStore(path=root / "sandboxes.json")
|
||||||
|
mgr = SandboxManager(store=store)
|
||||||
|
created = mgr.create(
|
||||||
|
SandboxCreateRequest(profile=profile.id, consumer=consumer), host="localhost"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# Different manager and store instances reconstruct the persisted owner handle.
|
||||||
|
resumed = SandboxManager(store=SandboxStore(path=root / "sandboxes.json"))
|
||||||
|
result = resumed.execute(
|
||||||
|
created.sandbox_id,
|
||||||
|
SandboxExecRequest(
|
||||||
|
command=["python3", "-c", proof.PROBE], consumer=consumer, timeout_seconds=40
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.stderr
|
||||||
|
finally:
|
||||||
|
SandboxManager(store=SandboxStore(path=root / "sandboxes.json")).destroy(
|
||||||
|
created.sandbox_id
|
||||||
|
)
|
||||||
|
assert not Path(created.inputs["egress_dir"]).exists()
|
||||||
|
assert not BwrapExtension._pid_alive(int(created.inputs["egress_pid"]))
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"sandbox_id": created.sandbox_id,
|
||||||
|
"manager_restart": True,
|
||||||
|
"proof": json.loads(result.stdout),
|
||||||
|
"proxy_removed": True,
|
||||||
|
"model_call": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
89
scripts/smoke-bwrap-egress.py
Normal file
89
scripts/smoke-bwrap-egress.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""Non-secret HTTPS reachability and denied egress proof."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sandboxer.extensions.bwrap import BwrapExtension
|
||||||
|
from sandboxer.models import Profile
|
||||||
|
|
||||||
|
PROBE = r"""
|
||||||
|
import json, os, socket, ssl, urllib.request, urllib.error
|
||||||
|
from pathlib import Path
|
||||||
|
proxy = os.environ['HTTPS_PROXY']
|
||||||
|
# GET without any credential: TLS and a provider HTTP response suffice.
|
||||||
|
client = urllib.request.build_opener(urllib.request.ProxyHandler({'https': proxy}))
|
||||||
|
try:
|
||||||
|
response = client.open('https://api.anthropic.com/', timeout=20)
|
||||||
|
status = response.status
|
||||||
|
response.close()
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
status = error.code
|
||||||
|
assert 100 <= status <= 599
|
||||||
|
try:
|
||||||
|
client.open('https://example.com/', timeout=5)
|
||||||
|
except (urllib.error.URLError, OSError):
|
||||||
|
denied = True
|
||||||
|
else:
|
||||||
|
raise AssertionError('undeclared destination reachable')
|
||||||
|
try:
|
||||||
|
socket.create_connection(('1.1.1.1',443),timeout=2)
|
||||||
|
except OSError:
|
||||||
|
direct_denied = True
|
||||||
|
else:
|
||||||
|
raise AssertionError('direct network reachable')
|
||||||
|
interfaces = Path('/proc/net/dev').read_text().splitlines()[2:]
|
||||||
|
assert [line.split(':')[0].strip() for line in interfaces] == ['lo']
|
||||||
|
print(json.dumps({'provider_http_status':status, 'tls_verified':True,
|
||||||
|
'undeclared_host_denied':denied, 'direct_network_denied':direct_denied}))
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with tempfile.TemporaryDirectory(prefix="sandboxer-egress-proof-") as directory:
|
||||||
|
extension = BwrapExtension(
|
||||||
|
{
|
||||||
|
"base_dir": directory,
|
||||||
|
"allowed_egress": ["api.anthropic.com:443"],
|
||||||
|
"ro_binds": ["/usr", "/bin", "/lib", "/lib64", "/etc/ssl/certs"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
profile = Profile(
|
||||||
|
id="profile.egress-proof",
|
||||||
|
version="1",
|
||||||
|
extension="ext.bwrap",
|
||||||
|
network={"default": "deny", "egress": ["api.anthropic.com:443"]},
|
||||||
|
)
|
||||||
|
handle = extension.provision(profile, {}, "localhost")
|
||||||
|
try:
|
||||||
|
extension.wait_ready(handle)
|
||||||
|
result = extension.execute(
|
||||||
|
handle,
|
||||||
|
["python3", "-c", PROBE],
|
||||||
|
credential_route_refs=[],
|
||||||
|
execution_context={},
|
||||||
|
timeout_seconds=40,
|
||||||
|
max_output_bytes=4096,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
teardown = extension.teardown(handle)
|
||||||
|
assert result["exit_code"] == 0, result
|
||||||
|
assert teardown["workspace_removed"] == "True"
|
||||||
|
assert not Path(handle["egress_dir"]).exists()
|
||||||
|
assert not extension._pid_alive(int(handle["egress_pid"]))
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"sandbox_id": handle["sandbox_id"],
|
||||||
|
"proof": json.loads(result["stdout"]),
|
||||||
|
"proxy_removed": True,
|
||||||
|
"workspace_removed": True,
|
||||||
|
"model_call": False,
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
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())
|
||||||
|
|
@ -75,6 +75,8 @@ class SandboxManager:
|
||||||
"provider_sandbox_id": status.inputs.get("provider_sandbox_id", ""),
|
"provider_sandbox_id": status.inputs.get("provider_sandbox_id", ""),
|
||||||
"provider": status.inputs.get("provider", ""),
|
"provider": status.inputs.get("provider", ""),
|
||||||
"pid": status.inputs.get("pid", ""),
|
"pid": status.inputs.get("pid", ""),
|
||||||
|
"egress_pid": status.inputs.get("egress_pid", ""),
|
||||||
|
"egress_dir": status.inputs.get("egress_dir", ""),
|
||||||
"workspace_dir": status.inputs.get("workspace_dir", ""),
|
"workspace_dir": status.inputs.get("workspace_dir", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,6 +168,8 @@ class SandboxManager:
|
||||||
status.inputs["provider_sandbox_id"] = handle.get("provider_sandbox_id", "")
|
status.inputs["provider_sandbox_id"] = handle.get("provider_sandbox_id", "")
|
||||||
status.inputs["provider"] = handle.get("provider", "")
|
status.inputs["provider"] = handle.get("provider", "")
|
||||||
status.inputs["pid"] = handle.get("pid", "")
|
status.inputs["pid"] = handle.get("pid", "")
|
||||||
|
status.inputs["egress_pid"] = handle.get("egress_pid", "")
|
||||||
|
status.inputs["egress_dir"] = handle.get("egress_dir", "")
|
||||||
status.inputs["workspace_dir"] = handle.get("workspace_dir", "")
|
status.inputs["workspace_dir"] = handle.get("workspace_dir", "")
|
||||||
reach = backend.wait_ready(handle)
|
reach = backend.wait_ready(handle)
|
||||||
reach = enrich_reachability(reach, profile, handle)
|
reach = enrich_reachability(reach, profile, handle)
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,15 @@ import shutil
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sandboxer.extensions.base import SandboxExtension
|
from sandboxer.extensions.base import SandboxExtension
|
||||||
|
from sandboxer.extensions.egress import destinations
|
||||||
|
from sandboxer.extensions.runtime import RUNTIME_MOUNT, verified_runtime
|
||||||
from sandboxer.models import Profile
|
from sandboxer.models import Profile
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -35,11 +38,9 @@ class BwrapExtension(SandboxExtension):
|
||||||
cfg = self.config
|
cfg = self.config
|
||||||
self.base_dir: str = cfg.get("base_dir", "/tmp/sandboxer-bwrap")
|
self.base_dir: str = cfg.get("base_dir", "/tmp/sandboxer-bwrap")
|
||||||
self.bwrap_bin: str = cfg.get("bwrap_bin", "bwrap")
|
self.bwrap_bin: str = cfg.get("bwrap_bin", "bwrap")
|
||||||
self.control_socket_name: str = cfg.get(
|
self.control_socket_name: str = cfg.get("control_socket_name", ".sandboxer-owner.sock")
|
||||||
"control_socket_name", ".sandboxer-owner.sock"
|
|
||||||
)
|
|
||||||
self.ro_binds: list[str] = cfg.get(
|
self.ro_binds: list[str] = cfg.get(
|
||||||
"ro_binds", ["/usr", "/bin", "/lib", "/lib64", "/etc/resolv.conf"]
|
"ro_binds", ["/usr", "/bin", "/lib", "/lib64", "/etc/resolv.conf", "/etc/ssl/certs"]
|
||||||
)
|
)
|
||||||
|
|
||||||
def _bwrap_bin(self) -> str:
|
def _bwrap_bin(self) -> str:
|
||||||
|
|
@ -48,7 +49,9 @@ class BwrapExtension(SandboxExtension):
|
||||||
def _existing_ro_binds(self) -> list[str]:
|
def _existing_ro_binds(self) -> list[str]:
|
||||||
return [path for path in self.ro_binds if Path(path).exists()]
|
return [path for path in self.ro_binds if Path(path).exists()]
|
||||||
|
|
||||||
def _bwrap_argv(self, workspace_dir: str, *, info_fd: int | None = None) -> list[str]:
|
def _bwrap_argv(
|
||||||
|
self, workspace_dir: str, *, info_fd: int | None = None, egress_socket: str | None = None
|
||||||
|
) -> list[str]:
|
||||||
argv = [
|
argv = [
|
||||||
self._bwrap_bin(),
|
self._bwrap_bin(),
|
||||||
"--unshare-user",
|
"--unshare-user",
|
||||||
|
|
@ -72,6 +75,17 @@ class BwrapExtension(SandboxExtension):
|
||||||
runner = Path(__file__).with_name("bwrap_runner.py")
|
runner = Path(__file__).with_name("bwrap_runner.py")
|
||||||
argv += ["--dir", "/run", "--dir", "/run/sandboxer"]
|
argv += ["--dir", "/run", "--dir", "/run/sandboxer"]
|
||||||
argv += ["--ro-bind", str(runner), "/run/sandboxer/bwrap_runner.py"]
|
argv += ["--ro-bind", str(runner), "/run/sandboxer/bwrap_runner.py"]
|
||||||
|
if egress_socket:
|
||||||
|
egress_module = Path(__file__).with_name("egress.py")
|
||||||
|
argv += ["--ro-bind", str(egress_module), "/run/sandboxer/egress.py"]
|
||||||
|
argv += ["--ro-bind", egress_socket, "/run/sandboxer/egress.sock"]
|
||||||
|
runtime = verified_runtime(self.config)
|
||||||
|
if runtime is not None:
|
||||||
|
workspace = Path(workspace_dir).resolve()
|
||||||
|
if runtime.is_relative_to(workspace) or workspace.is_relative_to(runtime):
|
||||||
|
raise ValueError("runtime and workspace must not overlap")
|
||||||
|
argv += ["--dir", "/opt", "--dir", "/opt/sandboxer"]
|
||||||
|
argv += ["--ro-bind", str(runtime), RUNTIME_MOUNT]
|
||||||
argv += ["--bind", workspace_dir, workspace_dir]
|
argv += ["--bind", workspace_dir, workspace_dir]
|
||||||
argv += ["--chdir", workspace_dir]
|
argv += ["--chdir", workspace_dir]
|
||||||
argv += [
|
argv += [
|
||||||
|
|
@ -80,6 +94,10 @@ class BwrapExtension(SandboxExtension):
|
||||||
workspace_dir,
|
workspace_dir,
|
||||||
f"{workspace_dir}/{self.control_socket_name}",
|
f"{workspace_dir}/{self.control_socket_name}",
|
||||||
]
|
]
|
||||||
|
if runtime is not None:
|
||||||
|
argv.append("--runtime")
|
||||||
|
if egress_socket:
|
||||||
|
argv.append("--egress")
|
||||||
return argv
|
return argv
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -99,9 +117,20 @@ class BwrapExtension(SandboxExtension):
|
||||||
raise RuntimeError("bwrap reported an invalid namespace child pid")
|
raise RuntimeError("bwrap reported an invalid namespace child pid")
|
||||||
return child_pid
|
return child_pid
|
||||||
|
|
||||||
def provision(
|
def provision(self, profile: Profile, inputs: dict[str, str], host: str) -> dict[str, str]:
|
||||||
self, profile: Profile, inputs: dict[str, str], host: str
|
if profile.network.default != "deny":
|
||||||
) -> dict[str, str]:
|
raise ValueError("bwrap requires default-deny networking")
|
||||||
|
requested = destinations(profile.network.egress)
|
||||||
|
allowed = destinations(self.config.get("allowed_egress", []))
|
||||||
|
if not requested.issubset(allowed):
|
||||||
|
raise ValueError("profile egress exceeds owner allowlist")
|
||||||
|
if profile.setup.secret_refs:
|
||||||
|
raise ValueError("bwrap has no setup credential delivery contract")
|
||||||
|
runtime = verified_runtime(self.config)
|
||||||
|
if runtime is not None and inputs.get("repo"):
|
||||||
|
source = Path(inputs["repo"]).resolve()
|
||||||
|
if source.is_relative_to(runtime) or runtime.is_relative_to(source):
|
||||||
|
raise ValueError("runtime and source checkout must not overlap")
|
||||||
sandbox_id = self.new_sandbox_id(inputs)
|
sandbox_id = self.new_sandbox_id(inputs)
|
||||||
workspace_dir = f"{self.base_dir}/{sandbox_id}"
|
workspace_dir = f"{self.base_dir}/{sandbox_id}"
|
||||||
Path(workspace_dir).mkdir(parents=True, exist_ok=True)
|
Path(workspace_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -114,22 +143,60 @@ class BwrapExtension(SandboxExtension):
|
||||||
shutil.copytree(repo_path, workspace_dir, dirs_exist_ok=True)
|
shutil.copytree(repo_path, workspace_dir, dirs_exist_ok=True)
|
||||||
Path(workspace_dir).chmod(0o700)
|
Path(workspace_dir).chmod(0o700)
|
||||||
|
|
||||||
info_read_fd, info_write_fd = os.pipe()
|
proxy = None
|
||||||
try:
|
proxy_dir = None
|
||||||
argv = self._bwrap_argv(workspace_dir, info_fd=info_write_fd)
|
egress_socket = None
|
||||||
proc = subprocess.Popen(
|
if requested:
|
||||||
argv,
|
import tempfile
|
||||||
|
|
||||||
|
proxy_dir = tempfile.mkdtemp(prefix="sandboxer-egress-")
|
||||||
|
egress_socket = str(Path(proxy_dir) / "proxy.sock")
|
||||||
|
proxy = subprocess.Popen(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(Path(__file__).with_name("egress.py")),
|
||||||
|
egress_socket,
|
||||||
|
*sorted(requested),
|
||||||
|
],
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
start_new_session=True,
|
start_new_session=True,
|
||||||
pass_fds=(info_write_fd,),
|
|
||||||
)
|
)
|
||||||
finally:
|
deadline = time.monotonic() + 5
|
||||||
os.close(info_write_fd)
|
while not Path(egress_socket).is_socket():
|
||||||
|
if proxy.poll() is not None or time.monotonic() >= deadline:
|
||||||
|
proxy.kill()
|
||||||
|
proxy.wait()
|
||||||
|
shutil.rmtree(proxy_dir)
|
||||||
|
raise RuntimeError("egress proxy failed to start")
|
||||||
|
time.sleep(0.02)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
child_pid = self._read_child_pid(proc, info_read_fd)
|
info_read_fd, info_write_fd = os.pipe()
|
||||||
finally:
|
try:
|
||||||
os.close(info_read_fd)
|
argv = self._bwrap_argv(
|
||||||
|
workspace_dir, info_fd=info_write_fd, egress_socket=egress_socket
|
||||||
|
)
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
argv,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True,
|
||||||
|
pass_fds=(info_write_fd,),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
os.close(info_write_fd)
|
||||||
|
try:
|
||||||
|
child_pid = self._read_child_pid(proc, info_read_fd)
|
||||||
|
finally:
|
||||||
|
os.close(info_read_fd)
|
||||||
|
except BaseException:
|
||||||
|
if proxy:
|
||||||
|
proxy.kill()
|
||||||
|
proxy.wait()
|
||||||
|
if proxy_dir:
|
||||||
|
shutil.rmtree(proxy_dir, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"sandbox_id": sandbox_id,
|
"sandbox_id": sandbox_id,
|
||||||
|
|
@ -137,9 +204,18 @@ class BwrapExtension(SandboxExtension):
|
||||||
"pid": str(child_pid),
|
"pid": str(child_pid),
|
||||||
"supervisor_pid": str(proc.pid),
|
"supervisor_pid": str(proc.pid),
|
||||||
"workspace_dir": workspace_dir,
|
"workspace_dir": workspace_dir,
|
||||||
|
**({"egress_pid": str(proxy.pid), "egress_dir": proxy_dir} if proxy else {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
|
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
|
||||||
|
try:
|
||||||
|
return self._wait_ready(handle)
|
||||||
|
except Exception:
|
||||||
|
if handle.get("egress_pid"):
|
||||||
|
self.teardown(handle)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
|
||||||
pid = int(handle["pid"])
|
pid = int(handle["pid"])
|
||||||
if not self._pid_alive(pid):
|
if not self._pid_alive(pid):
|
||||||
raise RuntimeError(f"bwrap process {pid} is not running")
|
raise RuntimeError(f"bwrap process {pid} is not running")
|
||||||
|
|
@ -233,6 +309,13 @@ class BwrapExtension(SandboxExtension):
|
||||||
os.kill(pid, signal.SIGKILL)
|
os.kill(pid, signal.SIGKILL)
|
||||||
killed = True
|
killed = True
|
||||||
|
|
||||||
|
if handle.get("egress_pid"):
|
||||||
|
with suppress(ProcessLookupError):
|
||||||
|
os.killpg(int(handle["egress_pid"]), signal.SIGKILL)
|
||||||
|
with suppress(ChildProcessError):
|
||||||
|
os.waitpid(int(handle["egress_pid"]), 0)
|
||||||
|
if handle.get("egress_dir"):
|
||||||
|
shutil.rmtree(handle["egress_dir"], ignore_errors=True)
|
||||||
workspace_dir = handle.get("workspace_dir", "")
|
workspace_dir = handle.get("workspace_dir", "")
|
||||||
removed = False
|
removed = False
|
||||||
if workspace_dir and Path(workspace_dir).exists():
|
if workspace_dir and Path(workspace_dir).exists():
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@ def _bounded_output(value: bytes | None, limit: int) -> tuple[str, bool]:
|
||||||
return raw.decode("utf-8", errors="replace"), truncated
|
return raw.decode("utf-8", errors="replace"), truncated
|
||||||
|
|
||||||
|
|
||||||
def _run(payload: dict, workspace: Path) -> dict[str, object]:
|
def _run(
|
||||||
|
payload: dict, workspace: Path, *, runtime_enabled: bool = False, proxy_port: int | None = None
|
||||||
|
) -> dict[str, object]:
|
||||||
command = payload["command"]
|
command = payload["command"]
|
||||||
timeout_seconds = int(payload["timeout_seconds"])
|
timeout_seconds = int(payload["timeout_seconds"])
|
||||||
max_output_bytes = int(payload["max_output_bytes"])
|
max_output_bytes = int(payload["max_output_bytes"])
|
||||||
|
|
@ -31,12 +33,23 @@ def _run(payload: dict, workspace: Path) -> dict[str, object]:
|
||||||
context = payload.get("execution_context", {})
|
context = payload.get("execution_context", {})
|
||||||
stdin_text = payload.get("stdin_text")
|
stdin_text = payload.get("stdin_text")
|
||||||
child_env = {
|
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",
|
"LANG": "C.UTF-8",
|
||||||
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||||
"SANDBOXER_CREDENTIAL_ROUTE_REFS": json.dumps(credential_refs),
|
"SANDBOXER_CREDENTIAL_ROUTE_REFS": json.dumps(credential_refs),
|
||||||
**{f"SANDBOXER_{key.upper()}": value for key, value in context.items()},
|
**{f"SANDBOXER_{key.upper()}": value for key, value in context.items()},
|
||||||
}
|
}
|
||||||
|
if runtime_enabled:
|
||||||
|
child_env["PATH"] = "/opt/sandboxer/runtime/bin:" + child_env["PATH"]
|
||||||
|
if proxy_port is not None:
|
||||||
|
child_env["HTTPS_PROXY"] = f"http://127.0.0.1:{proxy_port}"
|
||||||
|
child_env["https_proxy"] = child_env["HTTPS_PROXY"]
|
||||||
timed_out = False
|
timed_out = False
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
command,
|
command,
|
||||||
|
|
@ -49,9 +62,7 @@ def _run(payload: dict, workspace: Path) -> dict[str, object]:
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
stdin_bytes = stdin_text.encode("utf-8") if stdin_text is not None else None
|
stdin_bytes = stdin_text.encode("utf-8") if stdin_text is not None else None
|
||||||
stdout_raw, stderr_raw = process.communicate(
|
stdout_raw, stderr_raw = process.communicate(input=stdin_bytes, timeout=timeout_seconds)
|
||||||
input=stdin_bytes, timeout=timeout_seconds
|
|
||||||
)
|
|
||||||
exit_code = process.returncode
|
exit_code = process.returncode
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
timed_out = True
|
timed_out = True
|
||||||
|
|
@ -73,6 +84,18 @@ def _run(payload: dict, workspace: Path) -> dict[str, object]:
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
workspace = Path(sys.argv[1]).resolve(strict=True)
|
workspace = Path(sys.argv[1]).resolve(strict=True)
|
||||||
socket_path = Path(sys.argv[2])
|
socket_path = Path(sys.argv[2])
|
||||||
|
runtime_enabled = "--runtime" in sys.argv[3:]
|
||||||
|
if any(flag not in {"--runtime", "--egress"} for flag in sys.argv[3:]):
|
||||||
|
raise ValueError("invalid owner runtime mode")
|
||||||
|
proxy_port = None
|
||||||
|
if "--egress" in sys.argv[3:]:
|
||||||
|
from egress import bridge
|
||||||
|
|
||||||
|
proxy_port = bridge("/run/sandboxer/egress.sock")
|
||||||
|
state = Path("/run/sandboxer/state")
|
||||||
|
state.mkdir(mode=0o700)
|
||||||
|
for name in ("home", "config", "cache", "data", "tmp"):
|
||||||
|
(state / name).mkdir(mode=0o700)
|
||||||
socket_path.unlink(missing_ok=True)
|
socket_path.unlink(missing_ok=True)
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
|
||||||
server.bind(str(socket_path))
|
server.bind(str(socket_path))
|
||||||
|
|
@ -95,7 +118,12 @@ def main() -> int:
|
||||||
try:
|
try:
|
||||||
if not chunks:
|
if not chunks:
|
||||||
raise ValueError("empty or oversized execution request")
|
raise ValueError("empty or oversized execution request")
|
||||||
response = _run(json.loads(b"".join(chunks)), workspace)
|
response = _run(
|
||||||
|
json.loads(b"".join(chunks)),
|
||||||
|
workspace,
|
||||||
|
runtime_enabled=runtime_enabled,
|
||||||
|
proxy_port=proxy_port,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
response = {"boundary_error": str(exc)}
|
response = {"boundary_error": str(exc)}
|
||||||
connection.sendall(json.dumps(response).encode("utf-8"))
|
connection.sendall(json.dumps(response).encode("utf-8"))
|
||||||
|
|
|
||||||
130
src/sandboxer/extensions/egress.py
Normal file
130
src/sandboxer/extensions/egress.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
"""Bounded HTTPS CONNECT egress; host-side destination enforcement."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import select
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from contextlib import suppress
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def destinations(entries: list[str]) -> frozenset[str]:
|
||||||
|
"""Only exact lowercase DNS names with an explicit TLS port."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
if any(
|
||||||
|
not re.fullmatch(r"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}:443", x)
|
||||||
|
for x in entries
|
||||||
|
):
|
||||||
|
raise ValueError("egress requires exact lowercase DNS names with :443")
|
||||||
|
return frozenset(entries)
|
||||||
|
|
||||||
|
|
||||||
|
def connect_public(host: str) -> socket.socket:
|
||||||
|
addresses = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
|
||||||
|
if not addresses or any(not ipaddress.ip_address(a[4][0]).is_global for a in addresses):
|
||||||
|
raise ValueError("non-public destination")
|
||||||
|
# Connect to the already checked numeric address; never resolve a second time.
|
||||||
|
for family, kind, proto, _, address in addresses:
|
||||||
|
connection = socket.socket(family, kind, proto)
|
||||||
|
connection.settimeout(10)
|
||||||
|
try:
|
||||||
|
connection.connect(address)
|
||||||
|
return connection
|
||||||
|
except OSError:
|
||||||
|
connection.close()
|
||||||
|
raise OSError("destination unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
def relay(left: socket.socket, right: socket.socket) -> None:
|
||||||
|
deadline = time.monotonic() + 900
|
||||||
|
for connection in (left, right):
|
||||||
|
connection.settimeout(10)
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
ready, _, _ = select.select([left, right], [], [], 30)
|
||||||
|
if not ready:
|
||||||
|
return
|
||||||
|
for source in ready:
|
||||||
|
chunk = source.recv(65536)
|
||||||
|
if not chunk:
|
||||||
|
return
|
||||||
|
(right if source is left else left).sendall(chunk)
|
||||||
|
|
||||||
|
|
||||||
|
def tunnel(client: socket.socket, allowed: frozenset[str]) -> None:
|
||||||
|
client.settimeout(10)
|
||||||
|
header = bytearray()
|
||||||
|
# Do not consume any TLS bytes following CONNECT headers.
|
||||||
|
while not header.endswith(b"\r\n\r\n"):
|
||||||
|
byte = client.recv(1)
|
||||||
|
if not byte or len(header) >= 8192:
|
||||||
|
raise ValueError("invalid CONNECT header")
|
||||||
|
header.extend(byte)
|
||||||
|
lines = header.decode("ascii").split("\r\n")
|
||||||
|
request = lines[0].split(" ")
|
||||||
|
if len(request) != 3 or request[0] != "CONNECT" or request[2] != "HTTP/1.1":
|
||||||
|
raise ValueError("CONNECT required")
|
||||||
|
target = request[1]
|
||||||
|
if target not in allowed:
|
||||||
|
raise ValueError("destination denied")
|
||||||
|
# No request body or transfer framing on CONNECT.
|
||||||
|
for line in lines[1:-2]:
|
||||||
|
name, separator, value = line.partition(":")
|
||||||
|
if not separator or name.lower() not in {"host", "proxy-connection", "user-agent"}:
|
||||||
|
raise ValueError("unsupported CONNECT header")
|
||||||
|
if name.lower() == "host" and value.strip() != target:
|
||||||
|
raise ValueError("inconsistent host")
|
||||||
|
with connect_public(target[:-4]) as upstream:
|
||||||
|
client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||||
|
relay(client, upstream)
|
||||||
|
|
||||||
|
|
||||||
|
def serve(listener: socket.socket, handler) -> None:
|
||||||
|
slots = threading.BoundedSemaphore(16)
|
||||||
|
|
||||||
|
def worker(client):
|
||||||
|
try:
|
||||||
|
with client, suppress(OSError, ValueError, UnicodeError):
|
||||||
|
handler(client)
|
||||||
|
finally:
|
||||||
|
slots.release()
|
||||||
|
|
||||||
|
listener.listen(16)
|
||||||
|
while True:
|
||||||
|
client, _ = listener.accept()
|
||||||
|
if not slots.acquire(blocking=False):
|
||||||
|
client.close()
|
||||||
|
continue
|
||||||
|
threading.Thread(target=worker, args=(client,), daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def bridge(unix_path: str) -> int:
|
||||||
|
listener = socket.socket()
|
||||||
|
listener.bind(("127.0.0.1", 0))
|
||||||
|
port = listener.getsockname()[1]
|
||||||
|
|
||||||
|
def forward(client):
|
||||||
|
with socket.socket(socket.AF_UNIX) as upstream:
|
||||||
|
upstream.connect(unix_path)
|
||||||
|
relay(client, upstream)
|
||||||
|
|
||||||
|
threading.Thread(target=serve, args=(listener, forward), daemon=True).start()
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
allowed = destinations(sys.argv[2:])
|
||||||
|
with socket.socket(socket.AF_UNIX) as listener:
|
||||||
|
listener.bind(str(path))
|
||||||
|
path.chmod(0o600)
|
||||||
|
serve(listener, lambda client: tunnel(client, allowed))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
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
|
tunnel_via: str | None = None
|
||||||
identity: str | None = None
|
identity: str | None = None
|
||||||
# Local (no-SSH-hop) descriptor — populated for same-host extensions
|
# Local (no-SSH-hop) descriptor — populated for same-host extensions
|
||||||
# like ext.bwrap. A consumer execs into the sandbox directly
|
# like ext.bwrap. Commands enter through owner-mediated execute;
|
||||||
# (e.g. `nsenter --target <pid> ...`) rather than over SSH.
|
# the pid is lifecycle metadata, not consumer setns authority.
|
||||||
pid: str | None = None
|
pid: str | None = None
|
||||||
workspace_dir: 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) == {
|
assert set(child_env) == {
|
||||||
"HOME",
|
"HOME",
|
||||||
|
"XDG_CONFIG_HOME",
|
||||||
|
"XDG_CACHE_HOME",
|
||||||
|
"XDG_STATE_HOME",
|
||||||
|
"TMPDIR",
|
||||||
|
"PYTHONDONTWRITEBYTECODE",
|
||||||
|
"PYTHONNOUSERSITE",
|
||||||
"LANG",
|
"LANG",
|
||||||
"PATH",
|
"PATH",
|
||||||
"SANDBOXER_CREDENTIAL_ROUTE_REFS",
|
"SANDBOXER_CREDENTIAL_ROUTE_REFS",
|
||||||
"SANDBOXER_ACTOR",
|
"SANDBOXER_ACTOR",
|
||||||
"SANDBOXER_RUN_ID",
|
"SANDBOXER_RUN_ID",
|
||||||
}
|
}
|
||||||
|
assert child_env["HOME"] == "/run/sandboxer/state/home"
|
||||||
|
assert child_env["HOME"] != str(tmp_path)
|
||||||
assert result["stdout"] == "ok\n"
|
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="default-deny|owner allowlist"):
|
||||||
|
BwrapExtension({"base_dir": str(base)}).provision(profile, {}, "localhost")
|
||||||
|
assert not base.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_path_is_not_taken_from_command_payload(tmp_path):
|
||||||
|
process = MagicMock(returncode=0)
|
||||||
|
process.communicate.return_value = (b"", b"")
|
||||||
|
payload = {"command": ["rein-aharness", "--help"], "timeout_seconds": 10,
|
||||||
|
"max_output_bytes": 100, "runtime_path": "/host/source", "env": {"HOME": "/host"}}
|
||||||
|
with patch("sandboxer.extensions.bwrap_runner.subprocess.Popen", return_value=process) as run:
|
||||||
|
_run(payload, tmp_path, runtime_enabled=True)
|
||||||
|
env = run.call_args.kwargs["env"]
|
||||||
|
assert env["PATH"].split(":")[0] == RUNTIME_MOUNT + "/bin"
|
||||||
|
assert not Path(env["HOME"]).is_relative_to(tmp_path)
|
||||||
|
assert "/host" not in str(env)
|
||||||
|
assert env["PYTHONNOUSERSITE"] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_credentials_refuse_before_provisioning(tmp_path):
|
||||||
|
profile = Profile(id="profile.test", version="1", extension="ext.bwrap",
|
||||||
|
setup={"secret_refs": ["test-route"]})
|
||||||
|
base = tmp_path / "sandboxes"
|
||||||
|
with pytest.raises(ValueError, match="credential delivery contract"):
|
||||||
|
BwrapExtension({"base_dir": str(base)}).provision(profile, {}, "localhost")
|
||||||
|
assert not base.exists()
|
||||||
115
tests/test_egress.py
Normal file
115
tests/test_egress.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import socket
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sandboxer.extensions.egress import connect_public, destinations, tunnel
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"entry",
|
||||||
|
[
|
||||||
|
"*",
|
||||||
|
"api.anthropic.com",
|
||||||
|
"api.anthropic.com:80",
|
||||||
|
"127.0.0.1:443",
|
||||||
|
"API.anthropic.com:443",
|
||||||
|
"api.anthropic.com.evil:443/path",
|
||||||
|
"x@:443",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_destination(entry):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
destinations([entry])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"address", ["127.0.0.1", "10.0.0.1", "169.254.169.254", "::1", "::ffff:127.0.0.1"]
|
||||||
|
)
|
||||||
|
def test_nonpublic_dns_refused(address):
|
||||||
|
with (
|
||||||
|
patch("socket.getaddrinfo", return_value=[(socket.AF_INET, 1, 6, "", (address, 443))]),
|
||||||
|
patch("socket.socket") as factory,
|
||||||
|
pytest.raises(ValueError),
|
||||||
|
):
|
||||||
|
connect_public("api.anthropic.com")
|
||||||
|
factory.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"wire_request",
|
||||||
|
[
|
||||||
|
b"CONNECT evil.example:443 HTTP/1.1\r\n\r\n",
|
||||||
|
b"CONNECT api.anthropic.com:80 HTTP/1.1\r\n\r\n",
|
||||||
|
b"GET https://api.anthropic.com/ HTTP/1.1\r\n\r\n",
|
||||||
|
b"CONNECT api.anthropic.com:443 HTTP/1.1\r\nHost: evil.example:443\r\n\r\n",
|
||||||
|
b"CONNECT api.anthropic.com:443 HTTP/1.1\r\nContent-Length: 1\r\n\r\n",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_denied_connect_never_dials(wire_request):
|
||||||
|
left, right = socket.socketpair()
|
||||||
|
with left, right, patch("sandboxer.extensions.egress.connect_public") as connect:
|
||||||
|
left.sendall(wire_request)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
tunnel(right, destinations(["api.anthropic.com:443"]))
|
||||||
|
connect.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_connect_preserves_tls_bytes():
|
||||||
|
left, right = socket.socketpair()
|
||||||
|
upstream, peer = socket.socketpair()
|
||||||
|
with left, right, upstream, peer:
|
||||||
|
left.sendall(
|
||||||
|
b"CONNECT api.anthropic.com:443 HTTP/1.1\r\nHost: api.anthropic.com:443\r\n\r\nTLS"
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch("sandboxer.extensions.egress.connect_public", return_value=upstream) as connect,
|
||||||
|
patch("sandboxer.extensions.egress.relay") as relay,
|
||||||
|
):
|
||||||
|
tunnel(right, destinations(["api.anthropic.com:443"]))
|
||||||
|
connect.assert_called_once_with("api.anthropic.com")
|
||||||
|
relay.assert_called_once_with(right, upstream)
|
||||||
|
assert right.recv(3) == b"TLS"
|
||||||
|
assert b"200 Connection Established" in left.recv(100)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dns_result_is_used_without_second_resolution():
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"socket.getaddrinfo",
|
||||||
|
return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("1.1.1.1", 443))],
|
||||||
|
) as dns,
|
||||||
|
patch("socket.socket") as factory,
|
||||||
|
):
|
||||||
|
assert connect_public("api.anthropic.com") is factory.return_value
|
||||||
|
dns.assert_called_once()
|
||||||
|
factory.return_value.connect.assert_called_once_with(("1.1.1.1", 443))
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_cannot_expand_owner_allowlist(tmp_path):
|
||||||
|
from sandboxer.extensions.bwrap import BwrapExtension
|
||||||
|
from sandboxer.models import Profile
|
||||||
|
|
||||||
|
ext = BwrapExtension(
|
||||||
|
{"base_dir": str(tmp_path / "unused"), "allowed_egress": ["api.anthropic.com:443"]}
|
||||||
|
)
|
||||||
|
profile = Profile(
|
||||||
|
id="test",
|
||||||
|
version="1",
|
||||||
|
extension="ext.bwrap",
|
||||||
|
network={"default": "deny", "egress": ["example.com:443"]},
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="owner allowlist"):
|
||||||
|
ext.provision(profile, {}, "localhost")
|
||||||
|
assert not (tmp_path / "unused").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_broker_readiness_removes_egress():
|
||||||
|
from sandboxer.extensions.bwrap import BwrapExtension
|
||||||
|
ext = BwrapExtension()
|
||||||
|
handle = {"egress_pid": "123"}
|
||||||
|
with patch.object(ext, "_wait_ready", side_effect=RuntimeError("startup failed")), \
|
||||||
|
patch.object(ext, "teardown") as cleanup:
|
||||||
|
with pytest.raises(RuntimeError, match="startup failed"):
|
||||||
|
ext.wait_ready(handle)
|
||||||
|
cleanup.assert_called_once_with(handle)
|
||||||
|
|
@ -8,7 +8,7 @@ status: active
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: owner-mediated-execution
|
topic_slug: owner-mediated-execution
|
||||||
created: "2026-09-04"
|
created: "2026-09-04"
|
||||||
updated: "2026-09-04"
|
updated: "2026-09-05"
|
||||||
state_hub_workstream_id: "b616d1cd-208f-5ecf-a4a0-a028396422c4"
|
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
|
`ACTIVITY-WP-0032-T05`. Do not trigger the production pilot before readiness
|
||||||
changes.
|
changes.
|
||||||
|
|
||||||
This task depends on a reviewed Glas profile revision, the owner-fronted
|
This task depends on a reviewed Glas profile revision and its matching
|
||||||
`rein-openweights-openrouter-approle` read, and a deployed sand-boxer owner
|
credential/egress/runtime contract. Glas selected the Claude route first in
|
||||||
service. No credential value belongs in this workplan or State Hub.
|
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
|
## Acceptance criteria
|
||||||
|
|
||||||
|
|
|
||||||
119
workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md
Normal file
119
workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
---
|
||||||
|
id: SAND-WP-0015
|
||||||
|
type: workplan
|
||||||
|
title: "Provide a pinned bwrap rein runtime and private state"
|
||||||
|
domain: infotech
|
||||||
|
repo: sand-boxer
|
||||||
|
status: blocked
|
||||||
|
owner: codex
|
||||||
|
topic_slug: bwrap-runtime-and-private-state
|
||||||
|
created: "2026-09-05"
|
||||||
|
updated: "2026-09-05"
|
||||||
|
state_hub_workstream_id: "d3f12387-fd23-58f0-b979-9c811507614d"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Provide a pinned bwrap rein runtime and private state
|
||||||
|
|
||||||
|
Implement the runtime prerequisites returned by Glas in `GLAS-WP-0012` and
|
||||||
|
`docs/local-profile-acceptance.md`. Continue owner work `SAND-WP-0014-T05` and
|
||||||
|
live residual `GLAS-IN-0002`. Runtime startup is a separate acceptance gate
|
||||||
|
from credential delivery, enforced provider egress, and a real model task.
|
||||||
|
|
||||||
|
## Mount an owner-selected, digest-pinned Python runtime
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: SAND-WP-0015-T01
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "93e4aad8-ba9b-5850-93d4-b5bf78ac8978"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add exact artifact verification to trusted extension configuration and mount
|
||||||
|
the standalone runtime read-only at a fixed namespace path. Reject altered
|
||||||
|
content/modes, unexpected files, escaping symlinks, special files, and source
|
||||||
|
or workspace overlap. A caller cannot choose runtime paths through exec input.
|
||||||
|
Provide a non-editable rein-aharness/llm-connect bundle builder with recorded
|
||||||
|
source revisions and resolved dependency versions.
|
||||||
|
|
||||||
|
Completed 2026-09-05. `extensions/runtime.py` verifies complete artifact
|
||||||
|
contents/modes before a read-only mount; the builder installed committed
|
||||||
|
rein-aharness `1429db5` and llm-connect `0056094` without editable source paths.
|
||||||
|
Candidate digest and source/package evidence are in `docs/bwrap-runtime.md`.
|
||||||
|
|
||||||
|
## Keep writable runtime state outside the repository
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: SAND-WP-0015-T02
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "c8a689f8-bdf2-57a2-8521-a1c0ca44e750"
|
||||||
|
```
|
||||||
|
|
||||||
|
Create private mode-0700 HOME/config/cache/state/tmp directories within the
|
||||||
|
namespace, preserve them across exec requests, and remove them with teardown.
|
||||||
|
Keep the command environment sanitized and disable Python user-site/bytecode
|
||||||
|
writes. Fail closed on profile network/setup-credential declarations that the
|
||||||
|
current bwrap implementation cannot honor.
|
||||||
|
|
||||||
|
Completed 2026-09-05. Mode-0700 namespace HOME and XDG/TMP directories sit
|
||||||
|
outside the copied Git tree. Explicit runtime PATH selection remains owner
|
||||||
|
controlled. Unsupported egress/default-allow and setup credentials now refuse
|
||||||
|
before workspace creation; regression tests cover these boundaries.
|
||||||
|
|
||||||
|
## Verify the real rein runtime and owner regression paths
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: SAND-WP-0015-T03
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "1a3002f6-c6a8-59d0-9d38-942028419de5"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `make check`, the existing authenticated cross-request owner smoke, and a
|
||||||
|
real runtime startup smoke. Verify the actual rein CLI and adapter imports,
|
||||||
|
read-only runtime, private HOME, clean worktree, absent source, loopback-only
|
||||||
|
network, and teardown. Keep the production Glas profile blocked.
|
||||||
|
|
||||||
|
Completed 2026-09-05. `make check`: lint clean, 132 tests passed. Authenticated
|
||||||
|
owner API smoke `223db65b` returned HTTP 200 and proved exact consumer identity,
|
||||||
|
stdin delivery, absent source, loopback-only network and complete teardown.
|
||||||
|
Pinned runtime smoke `d4de9531` ran the actual rein CLI and imported its Claude
|
||||||
|
adapter, proved read-only runtime/private HOME/clean worktree, retained private
|
||||||
|
state across a second exec, and removed the workspace. No model call or
|
||||||
|
credential acquisition occurred; T04 remains waiting.
|
||||||
|
|
||||||
|
## Resolve Claude credentials, enforced egress, and production acceptance
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: SAND-WP-0015-T04
|
||||||
|
status: wait
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "58817ef6-76d9-5e34-908f-c024e6c99f93"
|
||||||
|
```
|
||||||
|
|
||||||
|
The 2026-09-05 routing lookup found no concrete Anthropic/Claude workload lane.
|
||||||
|
The generic OpenBao template is not a delegable lane; the OpenRouter AppRole
|
||||||
|
belongs to another rein. Credential owner railiance-platform/OpenBao plus
|
||||||
|
rein-aharness must establish a concrete Claude-compatible route and delivery/
|
||||||
|
revocation contract before values can be requested or supplied to the runtime.
|
||||||
|
|
||||||
|
Sand-boxer must then implement the matching enforced provider egress contract
|
||||||
|
(including DNS/TLS and denied-destination proof) and pin/deploy the actual
|
||||||
|
Claude executable. No unrestricted-network or interactive-login substitute is
|
||||||
|
permitted. Review the deployed runtime/profile with Glas, run its real-rein
|
||||||
|
acceptance fixture, and update only the proven profile's readiness. No
|
||||||
|
production schedule is enabled by the runtime-startup smoke.
|
||||||
|
|
||||||
|
This task and SAND-WP-0014-T05 remain open until those gates pass. The detailed
|
||||||
|
return contract and runtime proof are in `docs/bwrap-runtime.md`; the live
|
||||||
|
cross-repo residual remains `GLAS-IN-0002`.
|
||||||
|
|
||||||
|
## 2026-09-05 transport implementation evidence
|
||||||
|
|
||||||
|
T04 remains waiting on owner machine authentication, protected credential
|
||||||
|
delivery, pinned Claude startup and the real model proof. CCR-2026-0016 custody
|
||||||
|
is confirmed by a metadata-only check of live version 2; no value was read.
|
||||||
|
HTTPS egress is now implemented as an exact owner/profile allowlisted CONNECT
|
||||||
|
proxy while retaining an isolated network namespace. Non-secret live smoke
|
||||||
|
sandbox e290e788 verified provider TLS response, undeclared destination and
|
||||||
|
direct-IP denial, and proxy/workspace teardown. See docs/bwrap-egress.md.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue