feat: enhance container environment isolation via credential proxy (#798)

* feat: implement credential proxy for enhanced container environment isolation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review — bind proxy to loopback, scope OAuth injection, add tests

- Bind credential proxy to 127.0.0.1 instead of 0.0.0.0 (security)
- OAuth mode: only inject Authorization on token exchange endpoint
- Add 5 integration tests for credential-proxy.ts
- Remove dangling comment
- Extract host gateway into container-runtime.ts abstraction
- Update Apple Container skill for credential proxy compatibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: scope OAuth token injection by header presence instead of path

Path-based matching missed auth probe requests the CLI sends before
the token exchange. Now the proxy replaces Authorization only when
the container actually sends one, leaving x-api-key-only requests
(post-exchange) untouched.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: bind credential proxy to docker0 bridge IP on Linux

On bare-metal Linux Docker, containers reach the host via the bridge IP
(e.g. 172.17.0.1), not loopback. Detect the docker0 interface address
via os.networkInterfaces() and bind there instead of 0.0.0.0, so the
proxy is reachable by containers but not exposed to the LAN.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: bind credential proxy to loopback on WSL

WSL uses Docker Desktop with the same VM routing as macOS, so
127.0.0.1 is correct and secure. Without this, the fallback to
0.0.0.0 was triggered because WSL has no docker0 interface.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: detect WSL via /proc instead of env var

WSL_DISTRO_NAME isn't set under systemd. Use
/proc/sys/fs/binfmt_misc/WSLInterop which is always present on WSL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Gabi Simons
2026-03-09 00:27:13 +02:00
committed by GitHub
parent 8521e42f7b
commit 13ce4aaf67
14 changed files with 468 additions and 87 deletions

View File

@@ -10,19 +10,22 @@ import {
CONTAINER_IMAGE,
CONTAINER_MAX_OUTPUT_SIZE,
CONTAINER_TIMEOUT,
CREDENTIAL_PROXY_PORT,
DATA_DIR,
GROUPS_DIR,
IDLE_TIMEOUT,
TIMEZONE,
} from './config.js';
import { readEnvFile } from './env.js';
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
import { logger } from './logger.js';
import {
CONTAINER_HOST_GATEWAY,
CONTAINER_RUNTIME_BIN,
hostGatewayArgs,
readonlyMountArgs,
stopContainer,
} from './container-runtime.js';
import { detectAuthMode } from './credential-proxy.js';
import { validateAdditionalMounts } from './mount-security.js';
import { RegisteredGroup } from './types.js';
@@ -38,7 +41,6 @@ export interface ContainerInput {
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
secrets?: Record<string, string>;
}
export interface ContainerOutput {
@@ -199,14 +201,6 @@ function buildVolumeMounts(
return mounts;
}
/**
* Read allowed secrets from .env for passing to the container via stdin.
* Secrets are never written to disk or mounted as files.
*/
function readSecrets(): Record<string, string> {
return readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']);
}
function buildContainerArgs(
mounts: VolumeMount[],
containerName: string,
@@ -217,6 +211,23 @@ function buildContainerArgs(
// Pass host timezone so container's local time matches the user's
args.push('-e', `TZ=${TIMEZONE}`);
// Route API traffic through the credential proxy (containers never see real secrets)
args.push(
'-e',
`ANTHROPIC_BASE_URL=http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`,
);
// Mirror the host's auth method with a placeholder value.
const authMode = detectAuthMode();
if (authMode === 'api-key') {
args.push('-e', 'ANTHROPIC_API_KEY=placeholder');
} else {
args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder');
}
// Runtime-specific args for host gateway resolution
args.push(...hostGatewayArgs());
// Run as host user so bind-mounted files are accessible.
// Skip when running as root (uid 0), as the container's node user (uid 1000),
// or when getuid is unavailable (native Windows without WSL).
@@ -301,12 +312,8 @@ export async function runContainerAgent(
let stdoutTruncated = false;
let stderrTruncated = false;
// Pass secrets via stdin (never written to disk or mounted as files)
input.secrets = readSecrets();
container.stdin.write(JSON.stringify(input));
container.stdin.end();
// Remove secrets from input so they don't appear in logs
delete input.secrets;
// Streaming output: parse OUTPUT_START/END marker pairs as they arrive
let parseBuffer = '';

View File

@@ -24,10 +24,14 @@ Apple Container (VirtioFS) only supports directory mounts, not file mounts. The
- All exported interfaces unchanged: `ContainerInput`, `ContainerOutput`, `runContainerAgent`, `writeTasksSnapshot`, `writeGroupsSnapshot`, `AvailableGroup`
- Non-main containers behave identically (still get `--user` flag)
- Mount list for non-main containers is unchanged
- Secrets still passed via stdin, never mounted as files
- Credentials injected by host-side credential proxy, never in container env or stdin
- Output parsing (streaming + legacy) unchanged
## Must-keep
- The `isMain` parameter on `buildContainerArgs` (consumed by `runContainerAgent`)
- The `RUN_UID`/`RUN_GID` env vars for main containers (consumed by entrypoint.sh)
- The `--user` flag for non-main containers (file permission compatibility)
- `CONTAINER_HOST_GATEWAY` and `hostGatewayArgs()` imports from `container-runtime.js`
- `detectAuthMode()` import from `credential-proxy.js`
- `CREDENTIAL_PROXY_PORT` import from `config.js`
- Credential proxy env vars: `ANTHROPIC_BASE_URL`, `ANTHROPIC_API_KEY`/`CLAUDE_CODE_OAUTH_TOKEN`

View File

@@ -9,6 +9,20 @@ import { logger } from './logger.js';
/** The container runtime binary name. */
export const CONTAINER_RUNTIME_BIN = 'container';
/**
* Hostname containers use to reach the host machine.
* Apple Container VMs access the host via the default gateway (192.168.64.1).
*/
export const CONTAINER_HOST_GATEWAY = '192.168.64.1';
/**
* CLI args needed for the container to resolve the host gateway.
* Apple Container provides host networking natively on macOS — no extra args needed.
*/
export function hostGatewayArgs(): string[] {
return [];
}
/** Returns CLI args for a readonly bind mount. */
export function readonlyMountArgs(hostPath: string, containerPath: string): string[] {
return ['--mount', `type=bind,source=${hostPath},target=${containerPath},readonly`];

View File

@@ -20,8 +20,16 @@ Replaced Docker runtime with Apple Container runtime. This is a full file replac
- Changed: `docker ps --filter name=nanoclaw- --format '{{.Names}}'``container ls --format json` with JSON parsing
- Apple Container returns JSON with `{ status, configuration: { id } }` structure
### CONTAINER_HOST_GATEWAY
- Set to `'192.168.64.1'` — the default gateway for Apple Container VMs to reach the host
- Docker uses `'host.docker.internal'` which is resolved differently
### hostGatewayArgs
- Returns `[]` — Apple Container provides host networking natively on macOS
- Docker version returns `['--add-host=host.docker.internal:host-gateway']` on Linux
## Invariants
- All five exports remain identical: `CONTAINER_RUNTIME_BIN`, `readonlyMountArgs`, `stopContainer`, `ensureContainerRuntimeRunning`, `cleanupOrphans`
- All exports remain identical: `CONTAINER_RUNTIME_BIN`, `CONTAINER_HOST_GATEWAY`, `readonlyMountArgs`, `stopContainer`, `hostGatewayArgs`, `ensureContainerRuntimeRunning`, `cleanupOrphans`
- `stopContainer` implementation is unchanged (`<bin> stop <name>`)
- Logger usage pattern is unchanged
- Error handling pattern is unchanged
@@ -30,3 +38,4 @@ Replaced Docker runtime with Apple Container runtime. This is a full file replac
- The exported function signatures (consumed by container-runner.ts and index.ts)
- The error box-drawing output format
- The orphan cleanup logic (find + stop pattern)
- `CONTAINER_HOST_GATEWAY` must match the address the credential proxy is reachable at from within the VM