From 2cdbadee1f8fcaa93302d7debbfc529e19868ea4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 21 Feb 2026 11:43:53 +0100 Subject: [PATCH] fix(security): block startup-file env injection across host execution paths --- CHANGELOG.md | 1 + .../OpenClaw/ExecApprovalsSocket.swift | 29 +------- .../Sources/OpenClaw/HostEnvSanitizer.swift | 54 ++++++++++++++ .../OpenClaw/NodeMode/MacNodeRuntime.swift | 29 +------- src/agents/bash-tools.exec-runtime.ts | 30 +------- src/agents/skills.e2e.test.ts | 44 +++++++++++ src/agents/skills/env-overrides.ts | 43 ++++++----- src/config/config.env-vars.test.ts | 17 ++++- src/config/env-vars.ts | 7 ++ src/infra/host-env-security.test.ts | 51 +++++++++++++ src/infra/host-env-security.ts | 74 +++++++++++++++++++ src/node-host/invoke.sanitize-env.test.ts | 45 +++++++++-- src/node-host/invoke.ts | 41 +--------- 13 files changed, 318 insertions(+), 147 deletions(-) create mode 100644 apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift create mode 100644 src/infra/host-env-security.test.ts create mode 100644 src/infra/host-env-security.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index db6a9ac32fd..c720ff92fa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai - Docker/Security: run E2E and install-sh test images as non-root by adding appuser directives. Thanks @thewilloftheshadow. - Skills/Security: sanitize skill env overrides to block unsafe runtime injection variables and only allow sensitive keys when declared in skill metadata, with warnings for suspicious values. Thanks @thewilloftheshadow. - Security/Browser: block non-network browser navigation protocols (including `file:`, `data:`, and `javascript:`) while preserving `about:blank`, preventing local file reads via browser tool navigation. This ships in the next npm release. Thanks @q1uf3ng for reporting. +- Security/Exec: block shell startup-file env injection (`BASH_ENV`, `ENV`, `BASH_FUNC_*`, `LD_*`, `DYLD_*`) across config env ingestion, node-host inherited environment sanitization, and macOS exec host runtime to prevent pre-command execution from attacker-controlled environment variables. Thanks @tdjackey. - Security/Gateway/Hooks: block `__proto__`, `constructor`, and `prototype` traversal in webhook template path resolution to prevent prototype-chain payload data leakage in `messageTemplate` rendering. (#22213) Thanks @SleuthCo. - Security/OpenClawKit/UI: prevent injected inbound user context metadata blocks from leaking into chat history in TUI, webchat, and macOS surfaces by stripping all untrusted metadata prefixes at display boundaries. (#22142) Thanks @Mellowambience, @vincentkoc. - Security/OpenClawKit/UI: strip inbound metadata blocks from user messages in TUI rendering while preserving user-authored content. (#22345) Thanks @kansodata, @vincentkoc. diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift index e1432aaea1c..fdef131baba 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift @@ -364,21 +364,6 @@ private enum ExecHostExecutor { let skillAllow: Bool } - private static let blockedEnvKeys: Set = [ - "PATH", - "NODE_OPTIONS", - "PYTHONHOME", - "PYTHONPATH", - "PERL5LIB", - "PERL5OPT", - "RUBYOPT", - ] - - private static let blockedEnvPrefixes: [String] = [ - "DYLD_", - "LD_", - ] - static func handle(_ request: ExecHostRequest) async -> ExecHostResponse { let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } guard !command.isEmpty else { @@ -580,18 +565,8 @@ private enum ExecHostExecutor { error: nil) } - private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? { - guard let overrides else { return nil } - var merged = ProcessInfo.processInfo.environment - for (rawKey, value) in overrides { - let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !key.isEmpty else { continue } - let upper = key.uppercased() - if self.blockedEnvKeys.contains(upper) { continue } - if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue } - merged[key] = value - } - return merged + private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String] { + HostEnvSanitizer.sanitize(overrides: overrides) } } diff --git a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift new file mode 100644 index 00000000000..bbef0486fad --- /dev/null +++ b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift @@ -0,0 +1,54 @@ +import Foundation + +enum HostEnvSanitizer { + private static let blockedKeys: Set = [ + "NODE_OPTIONS", + "NODE_PATH", + "PYTHONHOME", + "PYTHONPATH", + "PERL5LIB", + "PERL5OPT", + "RUBYLIB", + "RUBYOPT", + "BASH_ENV", + "ENV", + "GCONV_PATH", + "IFS", + "SSLKEYLOGFILE", + ] + + private static let blockedPrefixes: [String] = [ + "DYLD_", + "LD_", + "BASH_FUNC_", + ] + + private static func isBlocked(_ upperKey: String) -> Bool { + if self.blockedKeys.contains(upperKey) { return true } + return self.blockedPrefixes.contains(where: { upperKey.hasPrefix($0) }) + } + + static func sanitize(overrides: [String: String]?) -> [String: String] { + var merged: [String: String] = [:] + for (rawKey, value) in ProcessInfo.processInfo.environment { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + let upper = key.uppercased() + if self.isBlocked(upper) { continue } + merged[key] = value + } + + guard let overrides else { return merged } + for (rawKey, value) in overrides { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + let upper = key.uppercased() + // PATH is part of the security boundary (command resolution + safe-bin checks). Never + // allow request-scoped PATH overrides from agents/gateways. + if upper == "PATH" { continue } + if self.isBlocked(upper) { continue } + merged[key] = value + } + return merged + } +} diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift index 60bd95f2894..0d096a1ef6b 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift @@ -862,33 +862,8 @@ extension MacNodeRuntime { UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false } - private static let blockedEnvKeys: Set = [ - "PATH", - "NODE_OPTIONS", - "PYTHONHOME", - "PYTHONPATH", - "PERL5LIB", - "PERL5OPT", - "RUBYOPT", - ] - - private static let blockedEnvPrefixes: [String] = [ - "DYLD_", - "LD_", - ] - - private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? { - guard let overrides else { return nil } - var merged = ProcessInfo.processInfo.environment - for (rawKey, value) in overrides { - let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !key.isEmpty else { continue } - let upper = key.uppercased() - if self.blockedEnvKeys.contains(upper) { continue } - if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue } - merged[key] = value - } - return merged + private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String] { + HostEnvSanitizer.sanitize(overrides: overrides) } private nonisolated static func locationMode() -> OpenClawLocationMode { diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index bc602255529..e342df6232b 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -3,6 +3,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import type { ExecAsk, ExecHost, ExecSecurity } from "../infra/exec-approvals.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; +import { isDangerousHostEnvVarName } from "../infra/host-env-security.js"; import { mergePathPrepend } from "../infra/path-prepend.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import type { ProcessSession } from "./bash-process-registry.js"; @@ -28,28 +29,6 @@ import { import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js"; import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js"; -// Security: Blocklist of environment variables that could alter execution flow -// or inject code when running on non-sandboxed hosts (Gateway/Node). -const DANGEROUS_HOST_ENV_VARS = new Set([ - "LD_PRELOAD", - "LD_LIBRARY_PATH", - "LD_AUDIT", - "DYLD_INSERT_LIBRARIES", - "DYLD_LIBRARY_PATH", - "NODE_OPTIONS", - "NODE_PATH", - "PYTHONPATH", - "PYTHONHOME", - "RUBYLIB", - "PERL5LIB", - "BASH_ENV", - "ENV", - "GCONV_PATH", - "IFS", - "SSLKEYLOGFILE", -]); -const DANGEROUS_HOST_ENV_PREFIXES = ["DYLD_", "LD_"]; - // Centralized sanitization helper. // Throws an error if dangerous variables or PATH modifications are detected on the host. export function validateHostEnv(env: Record): void { @@ -57,12 +36,7 @@ export function validateHostEnv(env: Record): void { const upperKey = key.toUpperCase(); // 1. Block known dangerous variables (Fail Closed) - if (DANGEROUS_HOST_ENV_PREFIXES.some((prefix) => upperKey.startsWith(prefix))) { - throw new Error( - `Security Violation: Environment variable '${key}' is forbidden during host execution.`, - ); - } - if (DANGEROUS_HOST_ENV_VARS.has(upperKey)) { + if (isDangerousHostEnvVarName(upperKey)) { throw new Error( `Security Violation: Environment variable '${key}' is forbidden during host execution.`, ); diff --git a/src/agents/skills.e2e.test.ts b/src/agents/skills.e2e.test.ts index 72dbc84af79..f23d914a480 100644 --- a/src/agents/skills.e2e.test.ts +++ b/src/agents/skills.e2e.test.ts @@ -351,6 +351,50 @@ describe("applySkillEnvOverrides", () => { } }); + it("blocks dangerous host env overrides even when declared", async () => { + const workspaceDir = await makeWorkspace(); + const skillDir = path.join(workspaceDir, "skills", "dangerous-env-skill"); + await writeSkill({ + dir: skillDir, + name: "dangerous-env-skill", + description: "Needs env", + metadata: '{"openclaw":{"requires":{"env":["BASH_ENV"]}}}', + }); + + const entries = loadWorkspaceSkillEntries(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + }); + + const originalBashEnv = process.env.BASH_ENV; + delete process.env.BASH_ENV; + + const restore = applySkillEnvOverrides({ + skills: entries, + config: { + skills: { + entries: { + "dangerous-env-skill": { + env: { + BASH_ENV: "/tmp/pwn.sh", + }, + }, + }, + }, + }, + }); + + try { + expect(process.env.BASH_ENV).toBeUndefined(); + } finally { + restore(); + if (originalBashEnv === undefined) { + expect(process.env.BASH_ENV).toBeUndefined(); + } else { + expect(process.env.BASH_ENV).toBe(originalBashEnv); + } + } + }); + it("allows required env overrides from snapshots", async () => { const workspaceDir = await makeWorkspace(); const skillDir = path.join(workspaceDir, "skills", "snapshot-env-skill"); diff --git a/src/agents/skills/env-overrides.ts b/src/agents/skills/env-overrides.ts index df19f79ef09..e2c736e36d6 100644 --- a/src/agents/skills/env-overrides.ts +++ b/src/agents/skills/env-overrides.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../../config/config.js"; +import { isDangerousHostEnvVarName } from "../../infra/host-env-security.js"; import { sanitizeEnvVars, validateEnvVarValue } from "../sandbox/sanitize-env-vars.js"; import { resolveSkillConfig } from "./config.js"; import { resolveSkillKey } from "./frontmatter.js"; @@ -13,18 +14,19 @@ type SanitizedSkillEnvOverrides = { warnings: string[]; }; -// Never allow skill env overrides that can alter runtime loader flags. -const HARD_BLOCKED_SKILL_ENV_PATTERNS: ReadonlyArray = [ - /^NODE_OPTIONS$/i, - /^OPENSSL_CONF$/i, - /^LD_PRELOAD$/i, - /^DYLD_INSERT_LIBRARIES$/i, -]; +// Always block skill env overrides that can alter runtime loading or host execution behavior. +const SKILL_ALWAYS_BLOCKED_ENV_PATTERNS: ReadonlyArray = [/^OPENSSL_CONF$/i]; function matchesAnyPattern(value: string, patterns: readonly RegExp[]): boolean { return patterns.some((pattern) => pattern.test(value)); } +function isAlwaysBlockedSkillEnvKey(key: string): boolean { + return ( + isDangerousHostEnvVarName(key) || matchesAnyPattern(key, SKILL_ALWAYS_BLOCKED_ENV_PATTERNS) + ); +} + function sanitizeSkillEnvOverrides(params: { overrides: Record; allowedSensitiveKeys: Set; @@ -33,19 +35,22 @@ function sanitizeSkillEnvOverrides(params: { return { allowed: {}, blocked: [], warnings: [] }; } - const result = sanitizeEnvVars(params.overrides, { - customBlockedPatterns: HARD_BLOCKED_SKILL_ENV_PATTERNS, - }); - const allowed = { ...result.allowed }; - const blocked: string[] = []; + const result = sanitizeEnvVars(params.overrides); + const allowed: Record = {}; + const blocked = new Set(); const warnings = [...result.warnings]; + for (const [key, value] of Object.entries(result.allowed)) { + if (isAlwaysBlockedSkillEnvKey(key)) { + blocked.add(key); + continue; + } + allowed[key] = value; + } + for (const key of result.blocked) { - if ( - matchesAnyPattern(key, HARD_BLOCKED_SKILL_ENV_PATTERNS) || - !params.allowedSensitiveKeys.has(key) - ) { - blocked.push(key); + if (isAlwaysBlockedSkillEnvKey(key) || !params.allowedSensitiveKeys.has(key)) { + blocked.add(key); continue; } const value = params.overrides[key]; @@ -55,7 +60,7 @@ function sanitizeSkillEnvOverrides(params: { const warning = validateEnvVarValue(value); if (warning) { if (warning === "Contains null bytes") { - blocked.push(key); + blocked.add(key); continue; } warnings.push(`${key}: ${warning}`); @@ -63,7 +68,7 @@ function sanitizeSkillEnvOverrides(params: { allowed[key] = value; } - return { allowed, blocked, warnings }; + return { allowed, blocked: [...blocked], warnings }; } function applySkillConfigEnvOverrides(params: { diff --git a/src/config/config.env-vars.test.ts b/src/config/config.env-vars.test.ts index 5b628c6fef9..37fa7f8fe48 100644 --- a/src/config/config.env-vars.test.ts +++ b/src/config/config.env-vars.test.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { loadDotEnv } from "../infra/dotenv.js"; import { resolveConfigEnvVars } from "./env-substitution.js"; -import { applyConfigEnvVars } from "./env-vars.js"; +import { applyConfigEnvVars, collectConfigEnvVars } from "./env-vars.js"; import { withEnvOverride, withTempHome } from "./test-helpers.js"; import type { OpenClawConfig } from "./types.js"; @@ -29,6 +29,21 @@ describe("config env vars", () => { }); }); + it("blocks dangerous startup env vars from config env", async () => { + await withEnvOverride({ BASH_ENV: undefined, OPENROUTER_API_KEY: undefined }, async () => { + const config = { + env: { vars: { BASH_ENV: "/tmp/pwn.sh", OPENROUTER_API_KEY: "config-key" } }, + }; + const entries = collectConfigEnvVars(config as OpenClawConfig); + expect(entries.BASH_ENV).toBeUndefined(); + expect(entries.OPENROUTER_API_KEY).toBe("config-key"); + + applyConfigEnvVars(config as OpenClawConfig); + expect(process.env.BASH_ENV).toBeUndefined(); + expect(process.env.OPENROUTER_API_KEY).toBe("config-key"); + }); + }); + it("loads ${VAR} substitutions from ~/.openclaw/.env on repeated runtime loads", async () => { await withTempHome(async (_home) => { await withEnvOverride({ BRAVE_API_KEY: undefined }, async () => { diff --git a/src/config/env-vars.ts b/src/config/env-vars.ts index 458674b75a3..cff37b1efd1 100644 --- a/src/config/env-vars.ts +++ b/src/config/env-vars.ts @@ -1,3 +1,4 @@ +import { isDangerousHostEnvVarName } from "../infra/host-env-security.js"; import type { OpenClawConfig } from "./types.js"; export function collectConfigEnvVars(cfg?: OpenClawConfig): Record { @@ -13,6 +14,9 @@ export function collectConfigEnvVars(cfg?: OpenClawConfig): Record { + it("matches dangerous keys and prefixes case-insensitively", () => { + expect(isDangerousHostEnvVarName("BASH_ENV")).toBe(true); + expect(isDangerousHostEnvVarName("bash_env")).toBe(true); + expect(isDangerousHostEnvVarName("DYLD_INSERT_LIBRARIES")).toBe(true); + expect(isDangerousHostEnvVarName("ld_preload")).toBe(true); + expect(isDangerousHostEnvVarName("BASH_FUNC_echo%%")).toBe(true); + expect(isDangerousHostEnvVarName("PATH")).toBe(false); + expect(isDangerousHostEnvVarName("FOO")).toBe(false); + }); +}); + +describe("sanitizeHostExecEnv", () => { + it("removes dangerous inherited keys while preserving PATH", () => { + const env = sanitizeHostExecEnv({ + baseEnv: { + PATH: "/usr/bin:/bin", + BASH_ENV: "/tmp/pwn.sh", + LD_PRELOAD: "/tmp/pwn.so", + OK: "1", + }, + }); + + expect(env).toEqual({ + PATH: "/usr/bin:/bin", + OK: "1", + }); + }); + + it("blocks PATH and dangerous override values", () => { + const env = sanitizeHostExecEnv({ + baseEnv: { + PATH: "/usr/bin:/bin", + HOME: "/tmp/home", + }, + overrides: { + PATH: "/tmp/evil", + BASH_ENV: "/tmp/pwn.sh", + SAFE: "ok", + }, + }); + + expect(env.PATH).toBe("/usr/bin:/bin"); + expect(env.BASH_ENV).toBeUndefined(); + expect(env.SAFE).toBe("ok"); + expect(env.HOME).toBe("/tmp/home"); + }); +}); diff --git a/src/infra/host-env-security.ts b/src/infra/host-env-security.ts new file mode 100644 index 00000000000..a3347c60834 --- /dev/null +++ b/src/infra/host-env-security.ts @@ -0,0 +1,74 @@ +const HOST_DANGEROUS_ENV_KEY_VALUES = [ + "NODE_OPTIONS", + "NODE_PATH", + "PYTHONHOME", + "PYTHONPATH", + "PERL5LIB", + "PERL5OPT", + "RUBYLIB", + "RUBYOPT", + "BASH_ENV", + "ENV", + "GCONV_PATH", + "IFS", + "SSLKEYLOGFILE", +] as const; + +export const HOST_DANGEROUS_ENV_KEYS = new Set(HOST_DANGEROUS_ENV_KEY_VALUES); +export const HOST_DANGEROUS_ENV_PREFIXES = ["DYLD_", "LD_", "BASH_FUNC_"] as const; + +export function isDangerousHostEnvVarName(key: string): boolean { + const upper = key.toUpperCase(); + if (HOST_DANGEROUS_ENV_KEYS.has(upper)) { + return true; + } + return HOST_DANGEROUS_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix)); +} + +export function sanitizeHostExecEnv(params?: { + baseEnv?: Record; + overrides?: Record | null; + blockPathOverrides?: boolean; +}): Record { + const baseEnv = params?.baseEnv ?? process.env; + const overrides = params?.overrides ?? undefined; + const blockPathOverrides = params?.blockPathOverrides ?? true; + + const merged: Record = {}; + for (const [rawKey, value] of Object.entries(baseEnv)) { + if (typeof value !== "string") { + continue; + } + const key = rawKey.trim(); + if (!key || isDangerousHostEnvVarName(key)) { + continue; + } + merged[key] = value; + } + + if (!overrides) { + return merged; + } + + for (const [rawKey, value] of Object.entries(overrides)) { + if (typeof value !== "string") { + continue; + } + const key = rawKey.trim(); + if (!key) { + continue; + } + const upper = key.toUpperCase(); + // PATH is part of the security boundary (command resolution + safe-bin checks). Never allow + // request-scoped PATH overrides from agents/gateways. + if (blockPathOverrides && upper === "PATH") { + continue; + } + if (isDangerousHostEnvVarName(upper)) { + continue; + } + merged[key] = value; + } + + return merged; +} diff --git a/src/node-host/invoke.sanitize-env.test.ts b/src/node-host/invoke.sanitize-env.test.ts index 589d6196029..f3a64ad9b47 100644 --- a/src/node-host/invoke.sanitize-env.test.ts +++ b/src/node-host/invoke.sanitize-env.test.ts @@ -7,7 +7,7 @@ describe("node-host sanitizeEnv", () => { const prev = process.env.PATH; process.env.PATH = "/usr/bin"; try { - const env = sanitizeEnv({ PATH: "/tmp/evil:/usr/bin" }) ?? {}; + const env = sanitizeEnv({ PATH: "/tmp/evil:/usr/bin" }); expect(env.PATH).toBe("/usr/bin"); } finally { if (prev === undefined) { @@ -21,18 +21,21 @@ describe("node-host sanitizeEnv", () => { it("blocks dangerous env keys/prefixes", () => { const prevPythonPath = process.env.PYTHONPATH; const prevLdPreload = process.env.LD_PRELOAD; + const prevBashEnv = process.env.BASH_ENV; try { delete process.env.PYTHONPATH; delete process.env.LD_PRELOAD; - const env = - sanitizeEnv({ - PYTHONPATH: "/tmp/pwn", - LD_PRELOAD: "/tmp/pwn.so", - FOO: "bar", - }) ?? {}; + delete process.env.BASH_ENV; + const env = sanitizeEnv({ + PYTHONPATH: "/tmp/pwn", + LD_PRELOAD: "/tmp/pwn.so", + BASH_ENV: "/tmp/pwn.sh", + FOO: "bar", + }); expect(env.FOO).toBe("bar"); expect(env.PYTHONPATH).toBeUndefined(); expect(env.LD_PRELOAD).toBeUndefined(); + expect(env.BASH_ENV).toBeUndefined(); } finally { if (prevPythonPath === undefined) { delete process.env.PYTHONPATH; @@ -44,6 +47,34 @@ describe("node-host sanitizeEnv", () => { } else { process.env.LD_PRELOAD = prevLdPreload; } + if (prevBashEnv === undefined) { + delete process.env.BASH_ENV; + } else { + process.env.BASH_ENV = prevBashEnv; + } + } + }); + + it("drops dangerous inherited env keys even without overrides", () => { + const prevPath = process.env.PATH; + const prevBashEnv = process.env.BASH_ENV; + try { + process.env.PATH = "/usr/bin:/bin"; + process.env.BASH_ENV = "/tmp/pwn.sh"; + const env = sanitizeEnv(undefined); + expect(env.PATH).toBe("/usr/bin:/bin"); + expect(env.BASH_ENV).toBeUndefined(); + } finally { + if (prevPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = prevPath; + } + if (prevBashEnv === undefined) { + delete process.env.BASH_ENV; + } else { + process.env.BASH_ENV = prevBashEnv; + } } }); }); diff --git a/src/node-host/invoke.ts b/src/node-host/invoke.ts index b5cbec1263d..d2c95b7e0b1 100644 --- a/src/node-host/invoke.ts +++ b/src/node-host/invoke.ts @@ -32,6 +32,7 @@ import { type ExecHostRunResult, } from "../infra/exec-host.js"; import { getTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js"; +import { sanitizeHostExecEnv } from "../infra/host-env-security.js"; import { validateSystemRunCommandConsistency } from "../infra/system-run-command.js"; import { runBrowserProxyCommand } from "./invoke-browser.js"; @@ -43,17 +44,6 @@ const execHostEnforced = process.env.OPENCLAW_NODE_EXEC_HOST?.trim().toLowerCase const execHostFallbackAllowed = process.env.OPENCLAW_NODE_EXEC_FALLBACK?.trim().toLowerCase() !== "0"; -const blockedEnvKeys = new Set([ - "NODE_OPTIONS", - "PYTHONHOME", - "PYTHONPATH", - "PERL5LIB", - "PERL5OPT", - "RUBYOPT", -]); - -const blockedEnvPrefixes = ["DYLD_", "LD_"]; - type SystemRunParams = { command: string[]; rawCommand?: string | null; @@ -136,33 +126,8 @@ function resolveExecAsk(value?: string): ExecAsk { return value === "off" || value === "on-miss" || value === "always" ? value : "on-miss"; } -export function sanitizeEnv( - overrides?: Record | null, -): Record | undefined { - if (!overrides) { - return undefined; - } - const merged = { ...process.env } as Record; - for (const [rawKey, value] of Object.entries(overrides)) { - const key = rawKey.trim(); - if (!key) { - continue; - } - const upper = key.toUpperCase(); - // PATH is part of the security boundary (command resolution + safe-bin checks). Never allow - // request-scoped PATH overrides from agents/gateways. - if (upper === "PATH") { - continue; - } - if (blockedEnvKeys.has(upper)) { - continue; - } - if (blockedEnvPrefixes.some((prefix) => upper.startsWith(prefix))) { - continue; - } - merged[key] = value; - } - return merged; +export function sanitizeEnv(overrides?: Record | null): Record { + return sanitizeHostExecEnv({ overrides, blockPathOverrides: true }); } function truncateOutput(raw: string, maxChars: number): { text: string; truncated: boolean } {