diff --git a/src/agents/sandbox/sanitize-env-vars.test.ts b/src/agents/sandbox/sanitize-env-vars.test.ts new file mode 100644 index 000000000..9367ef551 --- /dev/null +++ b/src/agents/sandbox/sanitize-env-vars.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeEnvVars } from "./sanitize-env-vars.js"; + +describe("sanitizeEnvVars", () => { + it("keeps normal env vars and blocks obvious credentials", () => { + const result = sanitizeEnvVars({ + NODE_ENV: "test", + OPENAI_API_KEY: "sk-live-xxx", + FOO: "bar", + GITHUB_TOKEN: "gh-token", + }); + + expect(result.allowed).toEqual({ + NODE_ENV: "test", + FOO: "bar", + }); + expect(result.blocked).toEqual(expect.arrayContaining(["OPENAI_API_KEY", "GITHUB_TOKEN"])); + }); + + it("blocks credentials even when suffix pattern matches", () => { + const result = sanitizeEnvVars({ + MY_TOKEN: "abc", + MY_SECRET: "def", + USER: "alice", + }); + + expect(result.allowed).toEqual({ USER: "alice" }); + expect(result.blocked).toEqual(expect.arrayContaining(["MY_TOKEN", "MY_SECRET"])); + }); + + it("adds warnings for suspicious values", () => { + const base64Like = + "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYQ=="; + const result = sanitizeEnvVars({ + USER: "alice", + SAFE_TEXT: base64Like, + NULL: "a\0b", + }); + + expect(result.allowed).toEqual({ USER: "alice", SAFE_TEXT: base64Like }); + expect(result.blocked).toContain("NULL"); + expect(result.warnings).toContain("SAFE_TEXT: Value looks like base64-encoded credential data"); + }); + + it("supports strict mode with explicit allowlist", () => { + const result = sanitizeEnvVars( + { + NODE_ENV: "test", + FOO: "bar", + }, + { strictMode: true }, + ); + + expect(result.allowed).toEqual({ NODE_ENV: "test" }); + expect(result.blocked).toEqual(["FOO"]); + }); +}); diff --git a/src/agents/sandbox/sanitize-env-vars.ts b/src/agents/sandbox/sanitize-env-vars.ts new file mode 100644 index 000000000..62bd4a299 --- /dev/null +++ b/src/agents/sandbox/sanitize-env-vars.ts @@ -0,0 +1,106 @@ +const BLOCKED_ENV_VAR_PATTERNS: ReadonlyArray = [ + /^ANTHROPIC_API_KEY$/i, + /^OPENAI_API_KEY$/i, + /^GEMINI_API_KEY$/i, + /^OPENROUTER_API_KEY$/i, + /^MINIMAX_API_KEY$/i, + /^ELEVENLABS_API_KEY$/i, + /^SYNTHETIC_API_KEY$/i, + /^TELEGRAM_BOT_TOKEN$/i, + /^DISCORD_BOT_TOKEN$/i, + /^SLACK_(BOT|APP)_TOKEN$/i, + /^LINE_CHANNEL_SECRET$/i, + /^LINE_CHANNEL_ACCESS_TOKEN$/i, + /^OPENCLAW_GATEWAY_(TOKEN|PASSWORD)$/i, + /^AWS_(SECRET_ACCESS_KEY|SECRET_KEY|SESSION_TOKEN)$/i, + /^(GH|GITHUB)_TOKEN$/i, + /^(AZURE|AZURE_OPENAI|COHERE|AI_GATEWAY|OPENROUTER)_API_KEY$/i, + /_?(API_KEY|TOKEN|PASSWORD|PRIVATE_KEY|SECRET)$/i, +]; + +const ALLOWED_ENV_VAR_PATTERNS: ReadonlyArray = [ + /^LANG$/, + /^LC_.*$/i, + /^PATH$/i, + /^HOME$/i, + /^USER$/i, + /^SHELL$/i, + /^TERM$/i, + /^TZ$/i, + /^NODE_ENV$/i, +]; + +export type EnvVarSanitizationResult = { + allowed: Record; + blocked: string[]; + warnings: string[]; +}; + +export type EnvSanitizationOptions = { + strictMode?: boolean; + customBlockedPatterns?: ReadonlyArray; + customAllowedPatterns?: ReadonlyArray; +}; + +function validateEnvVarValue(value: string): string | undefined { + if (value.includes("\0")) { + return "Contains null bytes"; + } + if (value.length > 32768) { + return "Value exceeds maximum length"; + } + if (/^[A-Za-z0-9+/=]{100,}$/.test(value)) { + return "Value looks like base64-encoded credential data"; + } + return undefined; +} + +function matchesAnyPattern(value: string, patterns: readonly RegExp[]): boolean { + return patterns.some((pattern) => pattern.test(value)); +} + +export function sanitizeEnvVars( + envVars: Record, + options: EnvSanitizationOptions = {}, +): EnvVarSanitizationResult { + const allowed: Record = {}; + const blocked: string[] = []; + const warnings: string[] = []; + + const blockedPatterns = [...BLOCKED_ENV_VAR_PATTERNS, ...(options.customBlockedPatterns ?? [])]; + const allowedPatterns = [...ALLOWED_ENV_VAR_PATTERNS, ...(options.customAllowedPatterns ?? [])]; + + for (const [rawKey, value] of Object.entries(envVars)) { + const key = rawKey.trim(); + if (!key) { + continue; + } + + if (matchesAnyPattern(key, blockedPatterns)) { + blocked.push(key); + continue; + } + + if (options.strictMode && !matchesAnyPattern(key, allowedPatterns)) { + blocked.push(key); + continue; + } + + const warning = validateEnvVarValue(value); + if (warning) { + warnings.push(`${key}: ${warning}`); + } + + allowed[key] = value; + } + + return { allowed, blocked, warnings }; +} + +export function getBlockedPatterns(): string[] { + return BLOCKED_ENV_VAR_PATTERNS.map((pattern) => pattern.source); +} + +export function getAllowedPatterns(): string[] { + return ALLOWED_ENV_VAR_PATTERNS.map((pattern) => pattern.source); +}