From 1a078693297c8d0fcd8d4990e796aa6f483ac80d Mon Sep 17 00:00:00 2001 From: Cole Date: Fri, 13 Feb 2026 12:33:39 -0800 Subject: [PATCH] security: sanitize env vars from agent Bash subprocesses (#171) Use a PreToolUse SDK hook to prepend `unset ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN` to every Bash command Kit runs, preventing secret leakage via env/printenv/echo/$PROC. Secrets are now passed via stdin JSON instead of mounted env files, closing all known exfiltration vectors. Co-authored-by: Claude Opus 4.6 --- container/Dockerfile | 5 +-- container/agent-runner/src/index.ts | 36 +++++++++++++++- src/container-runner.ts | 65 ++++++++++++++++------------- 3 files changed, 73 insertions(+), 33 deletions(-) diff --git a/container/Dockerfile b/container/Dockerfile index 992da17..d424ac5 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -51,10 +51,9 @@ 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 -# Sources env from mounted /workspace/env-dir/env if it exists (workaround for Apple Container -i bug) -# Stdin is buffered to /tmp then piped (Apple Container requires EOF to flush stdin pipe) +# Secrets are passed via stdin JSON and set in Node.js — no env files or temp files on disk # Follow-up messages arrive via IPC files in /workspace/ipc/input/ -RUN printf '#!/bin/bash\nset -e\n[ -f /workspace/env-dir/env ] && export $(cat /workspace/env-dir/env | xargs)\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 +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 # Set ownership to node user (non-root) for writable directories RUN chown -R node:node /workspace diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index c41cb60..82e1728 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -16,7 +16,7 @@ import fs from 'fs'; import path from 'path'; -import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk'; +import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk'; import { fileURLToPath } from 'url'; interface ContainerInput { @@ -26,6 +26,7 @@ interface ContainerInput { chatJid: string; isMain: boolean; isScheduledTask?: boolean; + secrets?: Record; } interface ContainerOutput { @@ -183,6 +184,30 @@ function createPreCompactHook(): 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), + command: unsetPrefix + command, + }, + }, + }; + }; +} + function sanitizeFilename(summary: string): string { return summary .toLowerCase() @@ -422,7 +447,8 @@ async function runQuery( }, }, hooks: { - PreCompact: [{ hooks: [createPreCompactHook()] }] + PreCompact: [{ hooks: [createPreCompactHook()] }], + PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }], }, } })) { @@ -477,6 +503,12 @@ async function main(): Promise { process.exit(1); } + // Set secrets as env vars for the SDK (needed for API auth). + // The PreToolUse hook strips these from every Bash subprocess. + for (const [key, value] of Object.entries(containerInput.secrets || {})) { + process.env[key] = value; + } + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js'); diff --git a/src/container-runner.ts b/src/container-runner.ts index 25c38b5..6080314 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -40,6 +40,7 @@ export interface ContainerInput { chatJid: string; isMain: boolean; isScheduledTask?: boolean; + secrets?: Record; } export interface ContainerOutput { @@ -157,33 +158,6 @@ function buildVolumeMounts( readonly: false, }); - // Environment file directory (workaround for Apple Container -i env var bug) - // Only expose specific auth variables needed by Claude Code, not the entire .env - const envDir = path.join(DATA_DIR, 'env'); - fs.mkdirSync(envDir, { recursive: true }); - const envFile = path.join(projectRoot, '.env'); - if (fs.existsSync(envFile)) { - const envContent = fs.readFileSync(envFile, 'utf-8'); - const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']; - const filteredLines = envContent.split('\n').filter((line) => { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) return false; - return allowedVars.some((v) => trimmed.startsWith(`${v}=`)); - }); - - if (filteredLines.length > 0) { - fs.writeFileSync( - path.join(envDir, 'env'), - filteredLines.join('\n') + '\n', - ); - mounts.push({ - hostPath: envDir, - containerPath: '/workspace/env-dir', - readonly: true, - }); - } - } - // Mount agent-runner source from host — recompiled on container startup. // Bypasses Apple Container's sticky build cache for code changes. const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src'); @@ -206,6 +180,38 @@ 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 { + const envFile = path.join(process.cwd(), '.env'); + if (!fs.existsSync(envFile)) return {}; + + const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']; + const secrets: Record = {}; + const content = fs.readFileSync(envFile, 'utf-8'); + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + if (!allowedVars.includes(key)) continue; + let value = trimmed.slice(eqIdx + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (value) secrets[key] = value; + } + + return secrets; +} + function buildContainerArgs(mounts: VolumeMount[], containerName: string): string[] { const args: string[] = ['run', '-i', '--rm', '--name', containerName]; @@ -280,9 +286,12 @@ export async function runContainerAgent( let stdoutTruncated = false; let stderrTruncated = false; - // Write input and close stdin (Apple Container doesn't flush pipe without EOF) + // 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 = '';