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
|
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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
Reference in New Issue
Block a user