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

@@ -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 = '';