Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
11 changes: 9 additions & 2 deletions src/container-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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?.();
Expand Down
95 changes: 95 additions & 0 deletions src/egress-lockdown.ts
Original file line number Diff line number Diff line change
@@ -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];
}
11 changes: 11 additions & 0 deletions src/host-sweep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -132,6 +133,16 @@ export function stopHostSweep(): void {
async function sweep(): Promise<void> {
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) {
Expand Down
Loading