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 <noreply@anthropic.com>
This commit is contained in:
Cole
2026-02-13 12:33:39 -08:00
committed by GitHub
parent c30bd62417
commit 1a07869329
3 changed files with 73 additions and 33 deletions

View File

@@ -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 RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input
# Create entrypoint script # Create entrypoint script
# Sources env from mounted /workspace/env-dir/env if it exists (workaround for Apple Container -i bug) # Secrets are passed via stdin JSON and set in Node.js — no env files or temp files on disk
# Stdin is buffered to /tmp then piped (Apple Container requires EOF to flush stdin pipe)
# Follow-up messages arrive via IPC files in /workspace/ipc/input/ # 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 # Set ownership to node user (non-root) for writable directories
RUN chown -R node:node /workspace RUN chown -R node:node /workspace

View File

@@ -16,7 +16,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; 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'; import { fileURLToPath } from 'url';
interface ContainerInput { interface ContainerInput {
@@ -26,6 +26,7 @@ interface ContainerInput {
chatJid: string; chatJid: string;
isMain: boolean; isMain: boolean;
isScheduledTask?: boolean; isScheduledTask?: boolean;
secrets?: Record<string, string>;
} }
interface ContainerOutput { 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<string, unknown>),
command: unsetPrefix + command,
},
},
};
};
}
function sanitizeFilename(summary: string): string { function sanitizeFilename(summary: string): string {
return summary return summary
.toLowerCase() .toLowerCase()
@@ -422,7 +447,8 @@ async function runQuery(
}, },
}, },
hooks: { hooks: {
PreCompact: [{ hooks: [createPreCompactHook()] }] PreCompact: [{ hooks: [createPreCompactHook()] }],
PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }],
}, },
} }
})) { })) {
@@ -477,6 +503,12 @@ async function main(): Promise<void> {
process.exit(1); 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 __dirname = path.dirname(fileURLToPath(import.meta.url));
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js'); const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');

View File

@@ -40,6 +40,7 @@ export interface ContainerInput {
chatJid: string; chatJid: string;
isMain: boolean; isMain: boolean;
isScheduledTask?: boolean; isScheduledTask?: boolean;
secrets?: Record<string, string>;
} }
export interface ContainerOutput { export interface ContainerOutput {
@@ -157,33 +158,6 @@ function buildVolumeMounts(
readonly: false, 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. // Mount agent-runner source from host — recompiled on container startup.
// Bypasses Apple Container's sticky build cache for code changes. // Bypasses Apple Container's sticky build cache for code changes.
const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src'); const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
@@ -206,6 +180,38 @@ function buildVolumeMounts(
return mounts; 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> {
const envFile = path.join(process.cwd(), '.env');
if (!fs.existsSync(envFile)) return {};
const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'];
const secrets: Record<string, string> = {};
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[] { function buildContainerArgs(mounts: VolumeMount[], containerName: string): string[] {
const args: string[] = ['run', '-i', '--rm', '--name', containerName]; const args: string[] = ['run', '-i', '--rm', '--name', containerName];
@@ -280,9 +286,12 @@ export async function runContainerAgent(
let stdoutTruncated = false; let stdoutTruncated = false;
let stderrTruncated = 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.write(JSON.stringify(input));
container.stdin.end(); 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 // Streaming output: parse OUTPUT_START/END marker pairs as they arrive
let parseBuffer = ''; let parseBuffer = '';