From 6420c0e254b63ab51ce13242cb93e188705cc7dc Mon Sep 17 00:00:00 2001 From: Omri Maya Date: Sun, 7 Jun 2026 14:38:47 +0300 Subject: [PATCH] =?UTF-8?q?feat(security):=20egress=20lockdown=20(opt-in)?= =?UTF-8?q?=20=E2=80=94=20agent=20egress=20only=20via=20OneCLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Place agent containers on a Docker `--internal` network (no internet route) with the OneCLI gateway attached, aliased host.docker.internal. The injected proxy URL resolves only to the gateway, so a non-proxy-aware client or raw socket has nowhere to go — closing the HTTPS_PROXY-bypass hole. The agent is non-root with no NET_ADMIN, so it cannot undo this. Self-healing: the gateway is re-attached at every spawn and on each host-sweep tick. Fail-fast: when lockdown is enabled but the network/gateway can't be established, refuse to spawn and surface a clear EgressLockdownError rather than silently falling back to open egress. The host-sweep re-heal is the lone exception — a heal failure there is logged, not fatal, since running agents stay on the internal net (no leak) until the gateway returns. Off by default — opt in with NANOCLAW_EGRESS_LOCKDOWN=true (so OSS users get the prior behavior unchanged on pull). Also NANOCLAW_EGRESS_NETWORK and ONECLI_GATEWAY_CONTAINER. The lockdown logic lives in its own src/egress-lockdown.ts; container-runtime.ts keeps only the generic runtime surface. Documented in docs/SECURITY.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/SECURITY.md | 42 ++++++++++++++++++ src/container-runner.ts | 11 ++++- src/egress-lockdown.ts | 95 +++++++++++++++++++++++++++++++++++++++++ src/host-sweep.ts | 11 +++++ 4 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 src/egress-lockdown.ts diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 2363b75060b..89152e92f9f 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -83,6 +83,48 @@ Each NanoClaw group gets its own OneCLI agent identity. This allows different cr - Any credentials matching blocked patterns - `.env` is shadowed with `/dev/null` in the project root mount +### 6. Egress Lockdown (Forced Proxy) + +The `HTTPS_PROXY` env var only redirects *proxy-aware* clients — a tool that +ignores it (or a raw socket) could reach the internet directly and bypass +credential injection, approvals, and audit. Egress lockdown closes that hole at +the network layer. + +**How it works:** agents are placed on a Docker `--internal` network +(`nanoclaw-egress`) that has **no route to the internet**. The OneCLI gateway +container is attached to that network, aliased as `host.docker.internal`, so the +injected proxy URL (`…@host.docker.internal:10255`) resolves to the gateway +*container-to-container*. The gateway is therefore the **only reachable hop** — +anything else has nowhere to go. The agent is non-root with no `NET_ADMIN`, so +it cannot undo this. Identical mechanism on macOS and Linux (no host firewall, +no `host-gateway` route). + +- **Self-healing:** the gateway is re-attached to the network at every spawn and + on each host-sweep tick, so an out-of-band detach (e.g. `docker compose up` on + the OneCLI stack — its compose lives in `~/.onecli`, not this repo) recovers + automatically. +- **Fail-fast:** if lockdown is on but the network can't be created or the + gateway can't be attached (e.g. a non-standard gateway container name, or the + gateway isn't running), nanoclaw **refuses to spawn the agent** and surfaces a + clear error — it never silently falls back to open egress. Fix the cause (or + set `NANOCLAW_EGRESS_LOCKDOWN=false`) and retry. The host-sweep re-heal is the + exception: a heal failure there is logged but not fatal, since already-running + agents stay on the internal net (no leak) until the gateway returns. + +**Configuration:** + +| Env | Default | Meaning | +| --- | --- | --- | +| `NANOCLAW_EGRESS_LOCKDOWN` | `false` | Set `true` to opt in (otherwise the host-gateway path is used). Enabled automatically by `/add-golden-registry`. | +| `NANOCLAW_EGRESS_NETWORK` | `nanoclaw-egress` | Network name. | +| `ONECLI_GATEWAY_CONTAINER` | `onecli` | Gateway container to attach. | + +**⚠ Behavior when enabled:** with lockdown on, agents have **no direct +internet** — all traffic must go through OneCLI. Proxy-aware clients (npm, pnpm, +pip, curl, node/bun with the proxy env) are unaffected. Any workflow that relies +on a **non-proxy-aware** tool reaching the internet directly will fail by design. +Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`. + ## Privilege Comparison | Capability | Main Group | Non-Main Group | diff --git a/src/container-runner.ts b/src/container-runner.ts index 7201bfcb513..ca59590d440 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -23,6 +23,7 @@ import { materializeContainerJson } from './container-config.js'; import { getContainerConfig } from './db/container-configs.js'; import { updateContainerConfigScalars, updateContainerConfigJson } from './db/container-configs.js'; import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js'; +import { EGRESS_NETWORK, egressNetworkArgs, ensureEgressNetwork } from './egress-lockdown.js'; import { composeGroupClaudeMd } from './claude-md-compose.js'; import { getAgentGroup } from './db/agent-groups.js'; import { getDb, hasTable } from './db/connection.js'; @@ -432,8 +433,14 @@ async function buildContainerArgs( } log.info('OneCLI gateway applied', { containerName }); - // Host gateway - args.push(...hostGatewayArgs()); + // Egress lockdown when enabled — throws if it can't be established, aborting + // the spawn rather than running with open egress. Otherwise the host gateway. + if (ensureEgressNetwork()) { + args.push(...egressNetworkArgs()); + log.info('Egress lockdown active', { containerName, network: EGRESS_NETWORK }); + } else { + args.push(...hostGatewayArgs()); + } // User mapping const hostUid = process.getuid?.(); diff --git a/src/egress-lockdown.ts b/src/egress-lockdown.ts new file mode 100644 index 00000000000..c05110ba1b2 --- /dev/null +++ b/src/egress-lockdown.ts @@ -0,0 +1,95 @@ +/** + * Egress lockdown — force ALL agent traffic through the OneCLI gateway. + * Agents run on a Docker `--internal` network (no internet route) with the + * gateway attached as host.docker.internal, so the injected proxy is the only + * reachable hop. Non-root, no NET_ADMIN — the agent can't undo it. + * + * Fail-fast: when the flag is on but the network/gateway can't be set up, throw + * rather than silently spawn an agent with open egress. + */ +import { execFileSync } from 'child_process'; + +import { CONTAINER_RUNTIME_BIN } from './container-runtime.js'; +import { log } from './log.js'; + +/** Locked-down, no-internet network agents are placed on. */ +export const EGRESS_NETWORK = process.env.NANOCLAW_EGRESS_NETWORK || 'nanoclaw-egress'; +/** The OneCLI gateway container attached as the only egress hop. */ +const ONECLI_GATEWAY_CONTAINER = process.env.ONECLI_GATEWAY_CONTAINER || 'onecli'; +/** Off by default; set NANOCLAW_EGRESS_LOCKDOWN=true to opt in. */ +const EGRESS_LOCKDOWN = process.env.NANOCLAW_EGRESS_LOCKDOWN === 'true'; + +/** Raised when lockdown is requested but can't be established. */ +export class EgressLockdownError extends Error { + constructor(reason: string) { + super( + `Egress lockdown is on (NANOCLAW_EGRESS_LOCKDOWN=true) but ${reason}. ` + + `Refusing to spawn with open egress. Start the OneCLI gateway container ` + + `"${ONECLI_GATEWAY_CONTAINER}", or set NANOCLAW_EGRESS_LOCKDOWN=false to opt out.`, + ); + this.name = 'EgressLockdownError'; + } +} + +function dockerOk(args: string[]): boolean { + try { + execFileSync(CONTAINER_RUNTIME_BIN, args, { stdio: 'pipe', timeout: 15000 }); + return true; + } catch { + return false; + } +} + +/** Is the OneCLI gateway currently attached to the egress network? */ +function gatewayAttached(): boolean { + try { + const out = execFileSync( + CONTAINER_RUNTIME_BIN, + ['network', 'inspect', EGRESS_NETWORK, '--format', '{{range .Containers}}{{.Name}} {{end}}'], + { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', timeout: 15000 }, + ); + return out.split(/\s+/).includes(ONECLI_GATEWAY_CONTAINER); + } catch { + return false; + } +} + +/** + * Ensure the egress network exists with the OneCLI gateway attached (aliased + * host.docker.internal). Idempotent + self-healing. Returns false when lockdown + * is disabled (caller uses the host gateway), true when it's active. Throws + * EgressLockdownError when enabled but unestablishable — fail fast rather than + * spawn an agent with open egress. + */ +export function ensureEgressNetwork(): boolean { + if (!EGRESS_LOCKDOWN) return false; + + if ( + !dockerOk(['network', 'inspect', EGRESS_NETWORK]) && + !dockerOk(['network', 'create', '--internal', EGRESS_NETWORK]) + ) { + throw new EgressLockdownError(`the "${EGRESS_NETWORK}" internal network could not be created`); + } + + if (gatewayAttached()) return true; + + if ( + dockerOk(['network', 'connect', '--alias', 'host.docker.internal', EGRESS_NETWORK, ONECLI_GATEWAY_CONTAINER]) && + gatewayAttached() + ) { + log.info('Egress lockdown: OneCLI gateway attached', { + network: EGRESS_NETWORK, + gateway: ONECLI_GATEWAY_CONTAINER, + }); + return true; + } + + throw new EgressLockdownError( + `the OneCLI gateway "${ONECLI_GATEWAY_CONTAINER}" could not be attached to "${EGRESS_NETWORK}"`, + ); +} + +/** CLI args placing a container on the locked-down egress network. */ +export function egressNetworkArgs(): string[] { + return ['--network', EGRESS_NETWORK]; +} diff --git a/src/host-sweep.ts b/src/host-sweep.ts index fbdd7e6a02f..64e2a521cef 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -29,6 +29,7 @@ import type Database from 'better-sqlite3'; import fs from 'fs'; +import { ensureEgressNetwork } from './egress-lockdown.js'; import { getActiveSessions } from './db/sessions.js'; import { getAgentGroup } from './db/agent-groups.js'; import { @@ -132,6 +133,16 @@ export function stopHostSweep(): void { async function sweep(): Promise { if (!running) return; + // Re-heal the egress network so already-running agents keep their gateway hop + // if it was detached out-of-band. Best-effort here: a heal failure isn't a + // leak (agents stay on the internal net), so log and continue. No-op when + // lockdown is disabled. + try { + ensureEgressNetwork(); + } catch (err) { + log.error('Egress lockdown re-heal failed', { err }); + } + try { const sessions = getActiveSessions(); for (const session of sessions) {