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:
@@ -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
|
||||
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
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 {
|
||||
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<void> {
|
||||
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');
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface ContainerInput {
|
||||
chatJid: string;
|
||||
isMain: boolean;
|
||||
isScheduledTask?: boolean;
|
||||
secrets?: Record<string, string>;
|
||||
}
|
||||
|
||||
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<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[] {
|
||||
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 = '';
|
||||
|
||||
Reference in New Issue
Block a user