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:
@@ -52,7 +52,8 @@ RUN npm run build
|
||||
RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input
|
||||
|
||||
# Create entrypoint script
|
||||
# Secrets are passed via stdin JSON — temp file is deleted immediately after Node reads it
|
||||
# Container input (prompt, group info) is passed via stdin JSON.
|
||||
# Credentials are injected by the host's credential proxy — never passed here.
|
||||
# Follow-up messages arrive via IPC files in /workspace/ipc/input/
|
||||
RUN printf '#!/bin/bash\nset -e\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\ncat > /tmp/input.json\nnode /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
interface ContainerInput {
|
||||
@@ -27,7 +27,6 @@ interface ContainerInput {
|
||||
isMain: boolean;
|
||||
isScheduledTask?: boolean;
|
||||
assistantName?: string;
|
||||
secrets?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ContainerOutput {
|
||||
@@ -185,30 +184,6 @@ function createPreCompactHook(assistantName?: string): HookCallback {
|
||||
};
|
||||
}
|
||||
|
||||
// Secrets to strip from Bash tool subprocess environments.
|
||||
// These are needed by claude-code for API auth but should never
|
||||
// be visible to commands Kit runs.
|
||||
const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'];
|
||||
|
||||
function createSanitizeBashHook(): HookCallback {
|
||||
return async (input, _toolUseId, _context) => {
|
||||
const preInput = input as PreToolUseHookInput;
|
||||
const command = (preInput.tool_input as { command?: string })?.command;
|
||||
if (!command) return {};
|
||||
|
||||
const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `;
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
updatedInput: {
|
||||
...(preInput.tool_input as Record<string, unknown>),
|
||||
command: unsetPrefix + command,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeFilename(summary: string): string {
|
||||
return summary
|
||||
.toLowerCase()
|
||||
@@ -451,7 +426,6 @@ async function runQuery(
|
||||
},
|
||||
hooks: {
|
||||
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
||||
PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }],
|
||||
},
|
||||
}
|
||||
})) {
|
||||
@@ -496,7 +470,6 @@ async function main(): Promise<void> {
|
||||
try {
|
||||
const stdinData = await readStdin();
|
||||
containerInput = JSON.parse(stdinData);
|
||||
// Delete the temp file the entrypoint wrote — it contains secrets
|
||||
try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ }
|
||||
log(`Received input for group: ${containerInput.groupFolder}`);
|
||||
} catch (err) {
|
||||
@@ -508,12 +481,9 @@ async function main(): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Build SDK env: merge secrets into process.env for the SDK only.
|
||||
// Secrets never touch process.env itself, so Bash subprocesses can't see them.
|
||||
// Credentials are injected by the host's credential proxy via ANTHROPIC_BASE_URL.
|
||||
// No real secrets exist in the container environment.
|
||||
const sdkEnv: Record<string, string | undefined> = { ...process.env };
|
||||
for (const [key, value] of Object.entries(containerInput.secrets || {})) {
|
||||
sdkEnv[key] = value;
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
|
||||
|
||||
Reference in New Issue
Block a user