diff --git a/.github/groom/README.md b/.github/groom/README.md index 3b4cec8..573be10 100644 --- a/.github/groom/README.md +++ b/.github/groom/README.md @@ -185,12 +185,19 @@ API key — while still letting it edit its worktree and reach Anthropic. ```bash agent-sandbox.sh --clone --clone-mode ro|rw-git-ro --out-dir \ - [--ro-file ...] [--env KEY=VALUE ...] -- + [--ro-file ...] [--env KEY=VALUE ...] [--uds ] \ + -- ``` - **[`broker.mjs`](broker.mjs)** — a ~50-line node-stdlib reverse proxy - (`node broker.mjs `) that holds the real key on the host and forwards the - jail's requests to it. + (`node broker.mjs `) that holds the real key on the host and + forwards the jail's requests to it. In socket mode it listens on a unix-domain + socket (bind-mounted into the jail via `--uds`); the legacy TCP port mode is + retained for the test plumbing and back-compat. +- **[`jail-shim.mjs`](jail-shim.mjs)** — a ~20-line node-stdlib TCP→UDS forwarder + (`node jail-shim.mjs /run/broker.sock`) that runs **inside** the jail so + agent tooling speaking HTTP to a `127.0.0.1:` base URL reaches the broker's + bind-mounted socket (the isolated netns has no way to dial a host TCP port). ### The sandbox contract (what the agent can and cannot see) @@ -204,7 +211,7 @@ API key — while still letting it edit its worktree and reach Anthropic. | host `$HOME` / `$RUNNER_TEMP` / `$GITHUB_WORKSPACE` / other repos | **invisible** | | host process table | **invisible** (own pid namespace) | | environment | **cleared** — only `HOME`, `PATH`, `TERM`, and each `--env KEY=VALUE`; nothing inherited from the host | -| network | shared (so the agent can reach the broker on loopback) | +| network | **isolated network namespace** (loopback only) — host network, host loopback, and cloud metadata are all **unreachable**; the broker is reached via a unix socket bind-mounted at `/run/broker.sock` plus the in-jail `jail-shim.mjs` TCP forwarder | The `rw-git-ro` worktree write is exactly how the builder's patch is captured: the agent edits tracked files, the wrapper's caller reads them back on the host @@ -215,8 +222,21 @@ afterward, but the agent can never rewrite git history or `.git/config`. `x-api-key` / `authorization` header, injects the real key, and forwards only `/v1/*` paths to `api.anthropic.com` — streaming the response through unbuffered so SSE works. `GET /healthz` answers locally; anything not under `/v1/` is `404`. -It listens on `127.0.0.1` only, refuses to start with an empty key, and logs one -line per request — method + path + status, never headers or body. +It listens on a unix-domain socket (`--uds`, the phase-2 default) or `127.0.0.1` +(legacy TCP mode), refuses to start with an empty key or a relative socket path, +and logs one line per request — method + path + status, never headers or body. The +request-handling contract is identical on both transports. + +**No network egress (BE-4421).** The jail runs in an isolated network namespace +with only loopback up, so the broker — reached over the unix socket bind-mounted +at `/run/broker.sock` via the in-jail `jail-shim.mjs` TCP→UDS forwarder — is the +*only* thing the agent can talk to. Host network, host loopback services, and +cloud metadata (`169.254.169.254` / `168.63.129.16`) are all unreachable. Two +consequences for callers: set `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` in the +agent env so the agent doesn't stall on telemetry endpoints that can never be +reached; and because there is no egress, in-jail `git fetch` / `npm install` +cannot work — anything the agent needs must already be in the clone before it is +sandboxed. ### The loud-preflight guarantee @@ -237,8 +257,11 @@ in [`test-groom-scripts.yml`](../workflows/test-groom-scripts.yml)) asserts ever row of the contract above with `bash -c` as the sandboxed command — env scrub, FS confinement + tmpfs shadowing, both clone modes, pid isolation — and points the broker at a local fake upstream ([`tests/fake-upstream.mjs`](tests/fake-upstream.mjs)) -to prove key injection/stripping, the `/healthz` + non-`/v1` behavior, and SSE -pass-through. No `claude`, no API key, no spend. +*over the bind-mounted unix socket + in-jail `jail-shim.mjs`* to prove key +injection/stripping, the `/healthz` + non-`/v1` behavior, and SSE pass-through. It +also proves the BE-4369 egress isolation: host loopback, cloud metadata, and an +arbitrary external IP are all unreachable from the jail. No `claude`, no API key, +no spend. ```bash shellcheck -x .github/groom/agent-sandbox.sh .github/groom/tests/sandbox-tests.sh diff --git a/.github/groom/agent-sandbox.sh b/.github/groom/agent-sandbox.sh index 1180a78..bbd2880 100755 --- a/.github/groom/agent-sandbox.sh +++ b/.github/groom/agent-sandbox.sh @@ -3,17 +3,28 @@ # agent-sandbox.sh — run an arbitrary command inside a bubblewrap (bwrap) jail. # # This is the confinement harness for the groom auto-builder's agent step -# (BE-4302, phase 1). It gives an untrusted agent a network-connected shell that -# can ONLY see: a read-only /usr + /etc, ephemeral /tmp + $HOME, the target clone -# (read-only, or read-write worktree with a read-only .git), an explicit set of -# read-only files, and one writable out-dir. Everything else on the host — other -# repos, the runner's secrets, $HOME, $RUNNER_TEMP, $GITHUB_WORKSPACE, the host -# process table — is invisible. The real API key never enters the jail; the agent -# reaches Anthropic only through the broker (broker.mjs) on host loopback. +# (BE-4302 phase 1; isolated-netns hardening BE-4421 phase 2). It gives an +# untrusted agent a shell in an ISOLATED network namespace — an empty netns with +# only loopback up (no host network, no host loopback services, no cloud metadata +# at 169.254.169.254 / 168.63.129.16) — that can ONLY see: a read-only /usr + /etc, +# ephemeral /tmp + $HOME, the target clone (read-only, or read-write worktree with +# a read-only .git), an explicit set of read-only files, and one writable out-dir. +# Everything else on the host — other repos, the runner's secrets, $HOME, +# $RUNNER_TEMP, $GITHUB_WORKSPACE, the host process table — is invisible. The real +# API key never enters the jail; the agent reaches Anthropic only through the +# broker (broker.mjs), bind-mounted into the jail as a unix-domain socket at +# /run/broker.sock (--uds). There is NO network egress — nothing else off-host is +# reachable, so in-jail `git fetch` / `npm install` cannot work. # # Usage: # agent-sandbox.sh --clone --clone-mode ro|rw-git-ro --out-dir \ -# [--ro-file ...] [--env KEY=VALUE ...] -- +# [--ro-file ...] [--env KEY=VALUE ...] [--uds ] \ +# -- +# +# --uds bind-mounts a host-side listening unix socket (the broker) to the fixed +# in-jail path /run/broker.sock (read-only: connect(2) to a socket works under a +# read-only bind, but the jail can't chmod/replace the shared inode). +# Omit it for a fully offline jail. # # The preflight FAILS LOUD: if a working bwrap sandbox cannot be established on # this runner image, the script exits non-zero and the command is NEVER run. It @@ -30,7 +41,7 @@ die() { # minus the caller-supplied clone/ro-file/out-dir/env. `true` runs as the probe. selftest() { bwrap \ - --unshare-all --share-net \ + --unshare-all \ --ro-bind /usr /usr \ --symlink usr/bin /bin \ --symlink usr/lib /lib \ @@ -86,7 +97,7 @@ PROFILE } main() { - local clone="" clone_mode="" out_dir="" + local clone="" clone_mode="" out_dir="" uds="" local ro_files=() envs=() cmd=() while [[ $# -gt 0 ]]; do @@ -96,6 +107,7 @@ main() { --out-dir) [[ $# -ge 2 ]] || die "--out-dir needs a value"; out_dir="$2"; shift 2 ;; --ro-file) [[ $# -ge 2 ]] || die "--ro-file needs a value"; ro_files+=("$2"); shift 2 ;; --env) [[ $# -ge 2 ]] || die "--env needs a value"; envs+=("$2"); shift 2 ;; + --uds) [[ $# -ge 2 ]] || die "--uds needs a value"; [[ -n "$2" ]] || die "--uds needs a non-empty value"; [[ -z "$uds" ]] || die "--uds may be given at most once"; uds="$2"; shift 2 ;; --) shift; cmd=("$@"); break ;; *) die "unknown argument: $1" ;; esac @@ -113,6 +125,22 @@ main() { *) die "--clone-mode must be 'ro' or 'rw-git-ro' (got '${clone_mode:-}')" ;; esac [[ -d "$clone" ]] || die "clone path is not a directory: $clone" + # The broker socket is bind-mounted at its real path, so require absolute; and + # require it to already be a listening unix socket — a rw --bind of a missing or + # non-socket path would just give the jail a useless mountpoint. Fail loud. + if [[ -n "$uds" ]]; then + [[ "$uds" = /* ]] || die "--uds must be an absolute path (got '$uds')" + [[ -S "$uds" ]] || die "--uds path is not a listening unix socket (start the broker first): $uds" + # -S only proves the inode is a socket, not that a broker is actually + # listening — a stale socket from a crashed broker would pass -S yet the + # in-jail connect() then fails at runtime, breaking the fail-loud-before- + # running guarantee. Probe /healthz over the socket to confirm a live + # listener (best-effort: only when curl is present, matching the tests). + if command -v curl >/dev/null 2>&1; then + curl -fsS --max-time 5 --unix-socket "$uds" http://broker/healthz >/dev/null 2>&1 \ + || die "--uds socket has no live broker listening (healthz probe failed): $uds" + fi + fi # out-dir must exist on the host before it can be bound rw into the jail; create # it here so we can canonicalize it for the overlap check below. @@ -134,7 +162,7 @@ main() { preflight local bwrap_args=( - --unshare-all --share-net --die-with-parent --new-session --clearenv + --unshare-all --die-with-parent --new-session --clearenv --ro-bind /usr /usr --symlink usr/bin /bin --symlink usr/lib /lib @@ -189,6 +217,19 @@ main() { done fi + # Bind the broker's unix socket into the isolated netns at a fixed path, READ- + # ONLY. connect(2) to a socket still works under a read-only bind — the kernel's + # read-only-fs EROFS check (sb_permission) fires only for regular files, dirs and + # symlinks, never for a socket inode — so the jail can still reach the broker, + # while --ro-bind additionally strips the agent's ability to chmod the shared + # socket inode (e.g. 000 to DoS itself, 0777 to widen host-side access). As a + # mountpoint the socket also can't be unlinked or replaced from inside the jail. + # bwrap auto-creates the dest. (Section 5 of sandbox-tests.sh exercises this exact + # in-jail connect over --ro-bind, so a regression here fails CI loudly.) + if [[ -n "$uds" ]]; then + bwrap_args+=(--ro-bind "$uds" /run/broker.sock) + fi + bwrap_args+=(--bind "$out_dir" "$out_dir" --chdir "$clone") # stdout/stderr pass through to the host shell; the caller redirects stdout diff --git a/.github/groom/broker.mjs b/.github/groom/broker.mjs index 5793878..52fdd7c 100644 --- a/.github/groom/broker.mjs +++ b/.github/groom/broker.mjs @@ -1,28 +1,69 @@ // broker.mjs — a zero-dependency reverse proxy that holds the real Anthropic key -// so a sandboxed agent never sees it (BE-4302, phase 1). +// so a sandboxed agent never sees it (BE-4302 phase 1; UDS transport BE-4421 phase 2). // -// node broker.mjs +// node broker.mjs // -// The agent inside the bwrap jail (agent-sandbox.sh) talks to this broker on host -// loopback with NO key of its own; the broker strips any inbound credential, -// injects the real ANTHROPIC_API_KEY (read from its own env, never the jail's), -// and forwards to api.anthropic.com. The key lives only in the host process; the -// jail can spend against it but can never read it. +// The agent inside the bwrap jail (agent-sandbox.sh) talks to this broker with NO +// key of its own; the broker strips any inbound credential, injects the real +// ANTHROPIC_API_KEY (read from its own env, never the jail's), and forwards to +// api.anthropic.com. The key lives only in the host process; the jail can spend +// against it but can never read it. // -// Contract: listens on 127.0.0.1 only; forwards only /v1/* paths; deletes inbound -// x-api-key / authorization before adding the real one; streams responses through -// unbuffered so SSE works; logs method + path + status ONLY (never headers/body). +// Transport: a numeric argv[2] keeps the legacy TCP mode (127.0.0.1:, +// retained for the fake-upstream test plumbing and back-compat); any other value +// is treated as an absolute unix-domain-socket path (the phase-2 default — the +// socket is bind-mounted into the isolated-netns jail at /run/broker.sock, since +// the jail has no network egress). A relative path is refused. +// +// Contract: listens on 127.0.0.1 (TCP mode) or a unix socket only; forwards only +// /v1/* paths; deletes inbound x-api-key / authorization before adding the real +// one; streams responses through unbuffered so SSE works; logs method + path + +// status ONLY (never headers/body). The request-handling contract is +// transport-independent — TCP and UDS behave identically. // // BROKER_UPSTREAM_HOST / BROKER_UPSTREAM_PORT override the upstream target for // tests only; production leaves them unset and pins api.anthropic.com:443. import http from 'node:http'; import https from 'node:https'; +import fs from 'node:fs'; -const port = Number(process.argv[2]); -if (!Number.isInteger(port) || port <= 0 || port > 65535) { - console.error('broker: usage: node broker.mjs '); - process.exit(1); +const arg = process.argv[2]; +let listenArgs; // spread into server.listen(): [port, host] for TCP or [path] for UDS +let addr; // human label for the listen log line +let uds; // the socket path in UDS mode (undefined in TCP mode); chmod'd after bind +if (arg !== undefined && /^\d+$/.test(arg)) { + const port = Number(arg); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + console.error('broker: usage: node broker.mjs '); + process.exit(1); + } + listenArgs = [port, '127.0.0.1']; + addr = `127.0.0.1:${port}`; +} else { + const sockPath = arg; + if (!sockPath || sockPath[0] !== '/') { + console.error('broker: usage: node broker.mjs (socket path must be absolute)'); + process.exit(1); + } + // Clear a stale socket left by a crashed prior run so listen() doesn't EADDRINUSE. + // Only ever unlink an actual SOCKET: an unconditional rmSync(force) would crash on + // a directory (EISDIR is not suppressed by force), silently delete a regular file + // at a mistyped path, and unlink a live socket. Stat first and fail loud on + // anything that isn't a socket. lstat so a symlink is treated as a non-socket. + try { + const st = fs.lstatSync(sockPath); + if (!st.isSocket()) { + console.error(`broker: refusing to start — ${sockPath} exists and is not a socket`); + process.exit(1); + } + fs.unlinkSync(sockPath); + } catch (e) { + if (e.code !== 'ENOENT') throw e; // absent path is the normal case + } + uds = sockPath; + listenArgs = [sockPath]; + addr = sockPath; } const KEY = process.env.ANTHROPIC_API_KEY; @@ -108,6 +149,17 @@ const server = http.createServer((req, res) => { req.pipe(upstream); // forward the request body streaming too }); -server.listen(port, '127.0.0.1', () => { - console.log(`broker listening on 127.0.0.1:${port} -> ${UPSTREAM_HOST}:${UPSTREAM_PORT}`); +// A listen failure (EADDRINUSE, EACCES, a bad UDS dir) would otherwise surface as +// an uncaught 'error' event and a raw stack trace; log it and exit non-zero. +server.on('error', (e) => { + console.error(`broker: listen failed on ${addr}: ${e.message}`); + process.exit(1); +}); + +server.listen(...listenArgs, () => { + // Lock the UDS to owner-only. server.listen() creates it under the process umask, + // so a permissive umask could let another local user connect(2) to this + // credential-injecting proxy and spend the real key (they still can't read it). + if (uds !== undefined) fs.chmodSync(uds, 0o600); + console.log(`broker listening on ${addr} -> ${UPSTREAM_HOST}:${UPSTREAM_PORT}`); }); diff --git a/.github/groom/jail-shim.mjs b/.github/groom/jail-shim.mjs new file mode 100644 index 0000000..c3dd30a --- /dev/null +++ b/.github/groom/jail-shim.mjs @@ -0,0 +1,44 @@ +// jail-shim.mjs — an in-jail TCP→UDS forwarder for the groom agent sandbox +// (BE-4421, phase 2). +// +// node jail-shim.mjs +// +// The sandbox (agent-sandbox.sh) runs the agent in an ISOLATED network namespace +// with only loopback up and no egress; the key-broker (broker.mjs) is reachable +// only as a unix-domain socket bind-mounted into the jail at /run/broker.sock. +// Agent tooling that speaks HTTP to a host:port (e.g. an Anthropic base URL) can't +// dial a unix socket, so this shim listens on jail-local loopback and forwards +// every connection to the socket. It runs INSIDE the jail as part of the +// sandboxed command (background it, then exec the agent); an agent that kills it +// only breaks its own API access. + +import net from 'node:net'; + +const port = Number(process.argv[2]); +const sockPath = process.argv[3]; +if (!Number.isInteger(port) || port <= 0 || port > 65535 || !sockPath) { + console.error('jail-shim: usage: node jail-shim.mjs '); + process.exit(1); +} + +const server = net.createServer((c) => { + const u = net.connect(sockPath); + c.pipe(u); + u.pipe(c); + c.on('error', () => u.destroy()); + u.on('error', () => c.destroy()); +}); + +// The shim is backgrounded inside the jail while the caller captures the command's +// stdout as the agent's JSON output; a listen failure surfacing as an uncaught +// 'error' would kill the shim with a raw stack trace, so handle it and exit clean. +server.on('error', (e) => { + console.error(`jail-shim: listen failed on 127.0.0.1:${port}: ${e.message}`); + process.exit(1); +}); + +server.listen(port, '127.0.0.1', () => { + // stderr, NOT stdout: the shim runs backgrounded alongside the agent whose stdout + // the caller captures as JSON — a banner on stdout would corrupt that capture. + console.error(`shim listening on 127.0.0.1:${port} -> ${sockPath}`); +}); diff --git a/.github/groom/tests/sandbox-tests.sh b/.github/groom/tests/sandbox-tests.sh index e797562..bcfaf6e 100755 --- a/.github/groom/tests/sandbox-tests.sh +++ b/.github/groom/tests/sandbox-tests.sh @@ -37,11 +37,13 @@ ws_canary="${GITHUB_WORKSPACE%/}/agent-sandbox-canary-ws.$$" fake_pid="" broker_pid="" host_sleep_pid="" +host_http_pid="" cleanup() { if [[ -n "$fake_pid" ]]; then kill "$fake_pid" 2>/dev/null || true; fi if [[ -n "$broker_pid" ]]; then kill "$broker_pid" 2>/dev/null || true; fi if [[ -n "$host_sleep_pid" ]]; then kill "$host_sleep_pid" 2>/dev/null || true; fi + if [[ -n "$host_http_pid" ]]; then kill "$host_http_pid" 2>/dev/null || true; fi rm -f "$home_canary" "$temp_canary" "$ws_canary" /tmp/canary rm -rf "$work" } @@ -157,10 +159,15 @@ kill "$host_sleep_pid" 2>/dev/null || true host_sleep_pid="" pass "pid isolation (host pids invisible in jail /proc)" -# --- 5. broker credential proxy ---------------------------------------------- +# --- 5. broker credential proxy (over the bind-mounted unix socket) ---------- # Fake local HTTPS upstream stands in for api.anthropic.com; the broker runs with -# the real (fake) key and a test-only TLS bypass for the self-signed upstream. +# the real (fake) key and a test-only TLS bypass for the self-signed upstream, and +# now listens on a UNIX SOCKET (no TCP port) bind-mounted into the isolated-netns +# jail via --uds. Inside the jail the in-jail forwarder (jail-shim.mjs) on +# 127.0.0.1:8790 bridges curl to that socket — exactly the composition the +# production groom caller will use — and ALL the existing assertions run against it. +SHIM="$ROOT/.github/groom/jail-shim.mjs" certdir="$work/certs" mkdir -p "$certdir" openssl req -x509 -newkey rsa:2048 -nodes \ @@ -169,7 +176,7 @@ openssl req -x509 -newkey rsa:2048 -nodes \ real_key="sk-ant-TESTFAKE-broker-forwarding-proof" up_port=8791 -broker_port=8790 +shim_port=8790 node "$FAKE_UPSTREAM" "$up_port" "$certdir/key.pem" "$certdir/cert.pem" & fake_pid=$! @@ -177,19 +184,28 @@ ANTHROPIC_API_KEY="$real_key" \ BROKER_UPSTREAM_HOST=127.0.0.1 \ BROKER_UPSTREAM_PORT="$up_port" \ NODE_TLS_REJECT_UNAUTHORIZED=0 \ - node "$BROKER" "$broker_port" & + node "$BROKER" "$work/broker.sock" & broker_pid=$! ready="" for _ in $(seq 1 50); do - if curl -fsS "http://127.0.0.1:$broker_port/healthz" >/dev/null 2>&1; then ready=1; break; fi + if curl -fsS --unix-socket "$work/broker.sock" http://broker/healthz >/dev/null 2>&1; then ready=1; break; fi sleep 0.2 done -[[ -n "$ready" ]] || fail "broker did not come up on 127.0.0.1:$broker_port" +[[ -n "$ready" ]] || fail "broker did not come up on $work/broker.sock" if ! "$SANDBOX" --clone "$clone" --clone-mode ro --out-dir "$outdir" \ - --env BROKERPORT="$broker_port" --env REALKEY="$real_key" -- bash -c ' - base="http://127.0.0.1:$BROKERPORT" + --uds "$work/broker.sock" --ro-file "$SHIM" \ + --env SHIM="$SHIM" --env SHIMPORT="$shim_port" --env REALKEY="$real_key" -- bash -c ' + # Bring up the in-jail TCP->UDS forwarder and wait for it before asserting. + node "$SHIM" "$SHIMPORT" /run/broker.sock & + base="http://127.0.0.1:$SHIMPORT" + shim_ready="" + for _ in $(seq 1 50); do + if curl -fsS "$base/healthz" >/dev/null 2>&1; then shim_ready=1; break; fi + sleep 0.2 + done + [ -n "$shim_ready" ] || { echo "jail-shim did not come up on $base"; exit 1; } # real key injected, caller-supplied dummy stripped body=$(curl -s "$base/v1/messages" -H "x-api-key: dummy") echo "$body" | grep -q "$REALKEY" || { echo "real key not forwarded upstream: $body"; exit 1; } @@ -211,17 +227,75 @@ if ! "$SANDBOX" --clone "$clone" --clone-mode ro --out-dir "$outdir" \ echo "$stream" | grep -q "\[DONE\]" || { echo "sse [DONE] missing"; exit 1; } exit 0 '; then fail "broker credential proxy"; fi -pass "broker credential proxy (key injected+stripped, healthz local, non-/v1 404, SSE streams)" +pass "broker credential proxy over UDS (key injected+stripped, healthz local, non-/v1 404, SSE streams)" # --- 6. broker crash-resilience: client disconnect mid-stream ---------------- # .pipe() puts no error listener on the client response, so a client that drops # mid-SSE would emit an unhandled 'error' on res and kill the whole broker. Force # that: /v1/stream sends one frame, waits 50ms, then the rest — abort inside the -# gap, then prove the broker is still serving. -curl -sN --max-time 0.02 "http://127.0.0.1:$broker_port/v1/stream" >/dev/null 2>&1 || true +# gap, then prove the broker is still serving. Host-side over the same UDS (the +# crash-proofing is transport-independent). +curl -sN --max-time 0.02 --unix-socket "$work/broker.sock" http://broker/v1/stream >/dev/null 2>&1 || true sleep 0.2 -curl -fsS "http://127.0.0.1:$broker_port/healthz" >/dev/null 2>&1 \ +curl -fsS --unix-socket "$work/broker.sock" http://broker/healthz >/dev/null 2>&1 \ || fail "broker crashed after a client disconnected mid-stream" pass "broker crash-resilience (survives a client mid-stream disconnect)" +# --- 7. egress isolation proofs (the BE-4369 acceptance tests) --------------- +# --unshare-all with no shared-network flag gives an empty netns with only lo up, so +# the broker's bind-mounted socket (proven in section 5) is the ONLY reachable service. +# Everything else off-host is unreachable BY CONSTRUCTION — prove it deterministically +# (connects fail immediately: ECONNREFUSED on the jail's own lo, ENETUNREACH off-netns; +# --max-time 3 is only a backstop, no real network or long timeouts are needed). + +# The 7a/7b/7c checks read a `curl` FAILURE as "unreachable" — so a curl missing +# from the jail PATH would make every branch false and green-light the tests +# without exercising the network control at all. Assert the tool is actually +# present in the jail first, so a missing dependency can't silently pass them. +"$SANDBOX" --clone "$clone" --clone-mode ro --out-dir "$outdir" -- bash -c 'command -v curl >/dev/null' \ + || fail "curl not available in jail — egress-isolation checks (7a/7b/7c) would false-pass" + +# 7a. Host loopback NOT reachable: a listener on the HOST's 127.0.0.1 sits on a +# different loopback than the jail's, so a jail connect must fail. Run this jail +# WITHOUT a shim on that port so nothing in-jail shadows it. +node -e 'require("http").createServer((q,s)=>s.end("host")).listen(8799, "127.0.0.1")' & +host_http_pid=$! +ready="" +for _ in $(seq 1 50); do + if curl -fsS "http://127.0.0.1:8799/" >/dev/null 2>&1; then ready=1; break; fi + sleep 0.2 +done +[[ -n "$ready" ]] || fail "host-loopback listener did not come up on 127.0.0.1:8799" +if ! "$SANDBOX" --clone "$clone" --clone-mode ro --out-dir "$outdir" -- bash -c ' + if curl -s --max-time 3 http://127.0.0.1:8799/ >/dev/null 2>&1; then + echo "host loopback 127.0.0.1:8799 reachable from jail — netns not isolated"; exit 1; fi + exit 0 +'; then fail "host loopback reachable from jail"; fi +kill "$host_http_pid" 2>/dev/null || true +host_http_pid="" +pass "host loopback unreachable from jail (jail lo != host lo)" + +# 7b. Cloud metadata endpoint NOT reachable (immediate ENETUNREACH in empty netns). +if ! "$SANDBOX" --clone "$clone" --clone-mode ro --out-dir "$outdir" -- bash -c ' + if curl -s --max-time 3 http://169.254.169.254/metadata/instance >/dev/null 2>&1; then + echo "cloud metadata 169.254.169.254 reachable from jail"; exit 1; fi + exit 0 +'; then fail "cloud metadata endpoint reachable from jail"; fi +pass "cloud metadata endpoint unreachable from jail" + +# 7c. Arbitrary external IP NOT reachable (IP literal, so no DNS dependence). +if ! "$SANDBOX" --clone "$clone" --clone-mode ro --out-dir "$outdir" -- bash -c ' + if curl -s --max-time 3 http://1.1.1.1/ >/dev/null 2>&1; then + echo "external IP 1.1.1.1 reachable from jail"; exit 1; fi + exit 0 +'; then fail "arbitrary external IP reachable from jail"; fi +pass "arbitrary external IP unreachable from jail" + +# 7d. --uds fail-loud: a nonexistent socket path must exit non-zero BEFORE the cmd. +if "$SANDBOX" --clone "$clone" --clone-mode ro --out-dir "$outdir" \ + --uds /nonexistent.sock -- true 2>/dev/null; then + fail "--uds /nonexistent.sock was accepted (must fail loud before running the command)" +fi +pass "--uds fail-loud on a nonexistent socket path" + echo "ALL SANDBOX TESTS PASSED"