VYPR
High severity7.5NVD Advisory· Published Apr 10, 2026· Updated Apr 13, 2026

CVE-2026-35650

CVE-2026-35650

Description

OpenClaw before 2026.3.22 contains an environment variable override handling vulnerability that allows attackers to bypass the shared host environment policy through inconsistent sanitization paths. Attackers can supply blocked or malformed override keys that slip through inconsistent validation to execute arbitrary code with unintended environment variables.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.222026.3.22

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.3.22

Patches

2
630f1479c44f

build: prepare 2026.3.23-2

https://github.com/openclaw/openclawPeter SteinbergerMar 24, 2026via ghsa
3 files changed · +26 24
  • CHANGELOG.md+24 22 modified
    @@ -8,45 +8,47 @@ Docs: https://docs.openclaw.ai
     
     ### Changes
     
    -- ModelStudio/Qwen: add standard (pay-as-you-go) DashScope endpoints for China and global Qwen API keys alongside the existing Coding Plan endpoints, and relabel the provider group to `Qwen (Alibaba Cloud Model Studio)`. (#43878)
    -- UI/clarity: consolidate button primitives (`btn--icon`, `btn--ghost`, `btn--xs`), refine the Knot theme to a black-and-red palette with WCAG 2.1 AA contrast, add config icons for Diagnostics/CLI/Secrets/ACP/MCP sections, replace the roundness slider with discrete stops, and improve accessibility with aria-labels across usage filters. (#53272) Thanks @BunsDev.
    -- CSP/Control UI: compute SHA-256 hashes for inline `<script>` blocks in the served `index.html` and include them in the `script-src` CSP directive, keeping inline scripts blocked by default while allowing explicitly hashed bootstrap code. (#53307) Thanks @BunsDev.
    -
     ### Fixes
     
    -- Plugins/ClawHub: resolve plugin API compatibility against the active runtime version at install time, and add regression coverage for current `>=2026.3.22` ClawHub package checks so installs no longer fail behind the stale `1.2.0` constant. (#53157) Thanks @futhgar.
    -- CLI/channel auth: auto-select the single login-capable configured channel for `channels login`/`logout` instead of relying on the outbound message-channel resolver, so env-only or non-auth channels no longer cause false ambiguity errors. (#53254) Thanks @BunsDev.
    -- Control UI/auth: preserve operator scopes through the device-auth bypass path, ignore cached under-scoped operator tokens, and show a clear `operator.read` fallback message when a connection really lacks read scope, so operator sessions stop failing or blanking on read-backed pages. (#53110) Thanks @BunsDev.
    -- Plugins/uninstall: accept installed `clawhub:` specs and versionless ClawHub package names as uninstall targets, so `openclaw plugins uninstall clawhub:<package>` works again even when the recorded install was pinned to a version.
    -- Auth/OpenAI tokens: stop live gateway auth-profile writes from reverting freshly saved credentials back to stale in-memory values, and make `models auth paste-token` write to the resolved agent store, so Configure, Onboard, and token-paste flows stop snapping back to expired OpenAI tokens. Fixes #53207. Related to #45516.
    -- Agents/failover: classify generic `api_error` payloads as retryable only when they include transient failure signals, so MiniMax-style backend failures still trigger model fallback without misclassifying billing, auth, or format/context errors. (#49611) Thanks @ayushozha.
    -- Diagnostics/cache trace: strip credential fields from cache-trace JSONL output while preserving non-sensitive diagnostic fields and image redaction metadata.
    -- Docs/Feishu: replace `botName` with `name` in the channel config examples so the docs match the strict account schema for per-account display names. (#52753) Thanks @haroldfabla2-hue.
    -- Doctor/plugins: make `openclaw doctor --fix` remove stale `plugins.allow` and `plugins.entries` refs left behind after plugin removal. Thanks @sallyom
    -- Agents/replay: canonicalize malformed assistant transcript content before session-history sanitization so legacy or corrupted assistant turns stop crashing Pi replay and subagent recovery paths.
    -- ClawHub/skills: keep updating already-tracked legacy Unicode slugs after the ASCII-only slug hardening, so older installs do not get stuck behind `Invalid skill slug` errors during `openclaw skills update`. (#53206) Thanks @drobison00.
    -- Infra/exec trust: preserve shell-multiplexer wrapper binaries for policy checks without breaking approved-command reconstruction, so BusyBox/ToyBox allowlist and audit flows bind to the real wrapper while execution plans stay coherent. (#53134) Thanks @vincentkoc.
    -- LINE/runtime-api: pre-export overlapping runtime symbols before the `line-runtime` star export so jiti no longer throws `TypeError: Cannot redefine property` on startup. (#53221) Thanks @Drickon.
    -- CLI/cron: make `openclaw cron add|edit --at ... --tz <iana>` honor the requested local wall-clock time for offset-less one-shot datetimes, including DST boundaries, and keep `--tz` rejected for `--every`. (#53224) Thanks @RolfHegr.
    -- Commands/auth: stop slash-command authorization from crashing or dropping valid allowlists when channel `allowFrom` resolution hits unresolved SecretRef-backed accounts, and fail closed only for the affected provider inference path. (#52791) Thanks @Lukavyi.
    -
     ## 2026.3.23
     
     ### Breaking
     
     ### Changes
     
    +- ModelStudio/Qwen: add standard (pay-as-you-go) DashScope endpoints for China and global Qwen API keys alongside the existing Coding Plan endpoints, and relabel the provider group to `Qwen (Alibaba Cloud Model Studio)`. (#43878)
    +- UI/clarity: consolidate button primitives (`btn--icon`, `btn--ghost`, `btn--xs`), refine the Knot theme to a black-and-red palette with WCAG 2.1 AA contrast, add config icons for Diagnostics/CLI/Secrets/ACP/MCP sections, replace the roundness slider with discrete stops, and improve accessibility with aria-labels across usage filters. (#53272) Thanks @BunsDev.
    +- CSP/Control UI: compute SHA-256 hashes for inline `<script>` blocks in the served `index.html` and include them in the `script-src` CSP directive, keeping inline scripts blocked by default while allowing explicitly hashed bootstrap code. (#53307) Thanks @BunsDev.
    +
     ### Fixes
     
    +- Plugins/bundled runtimes: ship bundled plugin runtime sidecars like WhatsApp `light-runtime-api.js`, Matrix `runtime-api.js`, and other plugin runtime entry files in the npm package again, so global installs stop failing on missing bundled plugin runtime surfaces.
    +- CLI/channel auth: auto-select the single configured login-capable channel for `channels login`/`logout`, harden channel ids against prototype-chain and control-character abuse, and fall back cleanly to catalog-backed channel installs, so channel auth works again for single-channel setups and on-demand channel installs. (#53254) Thanks @BunsDev.
    +- Auth/OpenAI tokens: stop live gateway auth-profile writes from reverting freshly saved credentials back to stale in-memory values, and make `models auth paste-token` write to the resolved agent store, so Configure, Onboard, and token-paste flows stop snapping back to expired OpenAI tokens. Fixes #53207. Related to #45516.
    +- Control UI/auth: preserve operator scopes through the device-auth bypass path, ignore cached under-scoped operator tokens, and show a clear `operator.read` fallback message when a connection really lacks read scope, so operator sessions stop failing or blanking on read-backed pages. (#53110) Thanks @BunsDev.
    +- Plugins/ClawHub: resolve plugin API compatibility against the active runtime version at install time, and add regression coverage for current `>=2026.3.22` ClawHub package checks so installs no longer fail behind the stale `1.2.0` constant. (#53157) Thanks @futhgar.
    +- Plugins/uninstall: accept installed `clawhub:` specs and versionless ClawHub package names as uninstall targets, so `openclaw plugins uninstall clawhub:<package>` works again even when the recorded install was pinned to a version.
     - Browser/Chrome MCP: wait for existing-session browser tabs to become usable after attach instead of treating the initial Chrome MCP handshake as ready, which reduces user-profile timeouts and repeated consent churn on macOS Chrome attach flows. Fixes #52930. Thanks @vincentkoc.
     - Browser/CDP: reuse an already-running loopback browser after a short initial reachability miss instead of immediately falling back to relaunch detection, which fixes second-run browser start/open regressions on slower headless Linux setups. Fixes #53004. Thanks @vincentkoc.
    +- Agents/web_search: use the active runtime `web_search` provider instead of stale/default selection, so agent turns keep hitting the provider you actually configured. Fixes #53020. Thanks @jzakirov.
    +- Mistral/models: lower bundled Mistral max-token defaults to safe output budgets and teach `openclaw doctor --fix` to repair old persisted Mistral provider configs that still carry context-sized output limits, avoiding deterministic Mistral 422 rejects on fresh and existing setups. Fixes #52599. Thanks @vincentkoc.
     - ClawHub/macOS auth: honor macOS auth config and XDG auth paths for saved ClawHub credentials, so `openclaw skills ...` and gateway skill browsing keep using the signed-in auth state instead of silently falling back to unauthenticated mode. Fixes #53034.
     - ClawHub/macOS: read the local ClawHub login from the macOS Application Support path and still honor XDG config on macOS, so skill browsing uses the logged-in token on both default and XDG-style setups. Fixes #52949. Thanks @scoootscooob.
     - ClawHub/skills: resolve the local ClawHub auth token for gateway skill browsing and switch browse-all requests to search so ClawControl stops falling into unauthenticated 429s and empty authenticated skill lists. Fixes #52949. Thanks @vincentkoc.
    +- Config/warnings: suppress the confusing “newer OpenClaw” warning when a config written by a same-base correction release like `2026.3.23-2` is read by `2026.3.23`, while still warning for truly newer or incompatible versions.
    +- CLI/cron: make `openclaw cron add|edit --at ... --tz <iana>` honor the requested local wall-clock time for offset-less one-shot datetimes, including DST boundaries, and keep `--tz` rejected for `--every`. (#53224) Thanks @RolfHegr.
    +- Commands/auth: stop slash-command authorization from crashing or dropping valid allowlists when channel `allowFrom` resolution hits unresolved SecretRef-backed accounts, and fail closed only for the affected provider inference path. (#52791) Thanks @Lukavyi.
    +- Agents/failover: classify generic `api_error` payloads as retryable only when they include transient failure signals, so MiniMax-style backend failures still trigger model fallback without misclassifying billing, auth, or format/context errors. (#49611) Thanks @ayushozha.
    +- LINE/runtime-api: pre-export overlapping runtime symbols before the `line-runtime` star export so jiti no longer throws `TypeError: Cannot redefine property` on startup. (#53221) Thanks @Drickon.
    +- Telegram/threading: populate `currentThreadTs` in the threading tool-context fallback for Telegram DM topics so thread-aware tools still receive the active topic context when the main thread metadata is missing. (#52217)
    +- Diagnostics/cache trace: strip credential fields from cache-trace JSONL output while preserving non-sensitive diagnostic fields and image redaction metadata.
    +- Docs/Feishu: replace `botName` with `name` in the channel config examples so the docs match the strict account schema for per-account display names. (#52753) Thanks @haroldfabla2-hue.
    +- Doctor/plugins: make `openclaw doctor --fix` remove stale `plugins.allow` and `plugins.entries` refs left behind after plugin removal. Thanks @sallyom
    +- Agents/replay: canonicalize malformed assistant transcript content before session-history sanitization so legacy or corrupted assistant turns stop crashing Pi replay and subagent recovery paths.
    +- ClawHub/skills: keep updating already-tracked legacy Unicode slugs after the ASCII-only slug hardening, so older installs do not get stuck behind `Invalid skill slug` errors during `openclaw skills update`. (#53206) Thanks @drobison00.
    +- Infra/exec trust: preserve shell-multiplexer wrapper binaries for policy checks without breaking approved-command reconstruction, so BusyBox/ToyBox allowlist and audit flows bind to the real wrapper while execution plans stay coherent. (#53134) Thanks @vincentkoc.
     - Plugins/message tool: make Discord `components` and Slack `blocks` optional again, and route Feishu `message(..., media=...)` sends through the outbound media path, so pin/unpin/react flows stop failing schema validation and Feishu file/image attachments actually send. Fixes #52970 and #52962. Thanks @vincentkoc.
     - Gateway/model pricing: stop `openrouter/auto` pricing refresh from recursing indefinitely during bootstrap, so OpenRouter auto routes can populate cached pricing and `usage.cost` again. Fixes #53035. Thanks @vincentkoc.
    -- Mistral/models: lower bundled Mistral max-token defaults to safe output budgets and teach `openclaw doctor --fix` to repair old persisted Mistral provider configs that still carry context-sized output limits, avoiding deterministic Mistral 422 rejects on fresh and existing setups. Fixes #52599. Thanks @vincentkoc.
    -- Agents/web_search: use the active runtime `web_search` provider instead of stale/default selection, so agent turns keep hitting the provider you actually configured. Fixes #53020. Thanks @jzakirov.
     - Models/OpenAI Codex OAuth: bootstrap the env-configured HTTP/HTTPS proxy dispatcher on the stored-credential refresh path before token renewal runs, so expired Codex OAuth profiles can refresh successfully in proxy-required environments instead of locking users out after the first token expiry.
     - Models/OpenAI Codex OAuth and Plugins/MiniMax OAuth: ensure env-configured HTTP/HTTPS proxy dispatchers are initialized before OAuth preflight and token exchange requests so proxy-required environments can complete MiniMax and OpenAI Codex sign-in flows again. (#52228; fixes #51619, #51569) Thanks @openperf.
     - Plugins/memory-lancedb: bootstrap LanceDB into plugin runtime state on first use when the bundled npm install does not already have it, so `plugins.slots.memory="memory-lancedb"` works again after global npm installs without moving LanceDB into OpenClaw core dependencies. Fixes #26100.
    
  • docs/.generated/plugin-sdk-api-baseline.json+1 1 modified
    @@ -2620,7 +2620,7 @@
               "exportName": "resolveCommandAuthorization",
               "kind": "function",
               "source": {
    -            "line": 303,
    +            "line": 440,
                 "path": "src/auto-reply/command-auth.ts"
               }
             },
    
  • docs/.generated/plugin-sdk-api-baseline.jsonl+1 1 modified
    @@ -287,7 +287,7 @@
     {"declaration":"export function parseCommandArgs(command: ChatCommandDefinition, raw?: string | undefined): CommandArgs | undefined;","entrypoint":"command-auth","exportName":"parseCommandArgs","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":254,"sourcePath":"src/auto-reply/commands-registry.ts"}
     {"declaration":"export function resolveCommandArgChoices(params: { command: ChatCommandDefinition; arg: CommandArgDefinition; cfg?: OpenClawConfig | undefined; provider?: string | undefined; model?: string | undefined; }): ResolvedCommandArgChoice[];","entrypoint":"command-auth","exportName":"resolveCommandArgChoices","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":316,"sourcePath":"src/auto-reply/commands-registry.ts"}
     {"declaration":"export function resolveCommandArgMenu(params: { command: ChatCommandDefinition; args?: CommandArgs | undefined; cfg?: OpenClawConfig | undefined; }): { arg: CommandArgDefinition; choices: ResolvedCommandArgChoice[]; title?: string | undefined; } | null;","entrypoint":"command-auth","exportName":"resolveCommandArgMenu","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":346,"sourcePath":"src/auto-reply/commands-registry.ts"}
    -{"declaration":"export function resolveCommandAuthorization(params: { ctx: MsgContext; cfg: OpenClawConfig; commandAuthorized: boolean; }): CommandAuthorization;","entrypoint":"command-auth","exportName":"resolveCommandAuthorization","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":303,"sourcePath":"src/auto-reply/command-auth.ts"}
    +{"declaration":"export function resolveCommandAuthorization(params: { ctx: MsgContext; cfg: OpenClawConfig; commandAuthorized: boolean; }): CommandAuthorization;","entrypoint":"command-auth","exportName":"resolveCommandAuthorization","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":440,"sourcePath":"src/auto-reply/command-auth.ts"}
     {"declaration":"export function resolveCommandAuthorizedFromAuthorizers(params: { useAccessGroups: boolean; authorizers: CommandAuthorizer[]; modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff | undefined; }): boolean;","entrypoint":"command-auth","exportName":"resolveCommandAuthorizedFromAuthorizers","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":8,"sourcePath":"src/channels/command-gating.ts"}
     {"declaration":"export function resolveControlCommandGate(params: { useAccessGroups: boolean; authorizers: CommandAuthorizer[]; allowTextCommands: boolean; hasControlCommand: boolean; modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff | undefined; }): { ...; };","entrypoint":"command-auth","exportName":"resolveControlCommandGate","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":31,"sourcePath":"src/channels/command-gating.ts"}
     {"declaration":"export function resolveDirectDmAuthorizationOutcome(params: { isGroup: boolean; dmPolicy: string; senderAllowedForCommands: boolean; }): \"disabled\" | \"unauthorized\" | \"allowed\";","entrypoint":"command-auth","exportName":"resolveDirectDmAuthorizationOutcome","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":120,"sourcePath":"src/plugin-sdk/command-auth.ts"}
    
7abfff756d6c

Exec: harden host env override handling across gateway and node (#51207)

https://github.com/openclaw/openclawJosh AvantMar 20, 2026via ghsa
14 files changed · +507 44
  • apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift+67 2 modified
    @@ -1,5 +1,10 @@
     import Foundation
     
    +struct HostEnvOverrideDiagnostics: Equatable {
    +    var blockedKeys: [String]
    +    var invalidKeys: [String]
    +}
    +
     enum HostEnvSanitizer {
         /// Generated from src/infra/host-env-security-policy.json via scripts/generate-host-env-security-policy-swift.mjs.
         /// Parity is validated by src/infra/host-env-security.policy-parity.test.ts.
    @@ -41,6 +46,67 @@ enum HostEnvSanitizer {
             return filtered.isEmpty ? nil : filtered
         }
     
    +    private static func isPortableHead(_ scalar: UnicodeScalar) -> Bool {
    +        let value = scalar.value
    +        return value == 95 || (65...90).contains(value) || (97...122).contains(value)
    +    }
    +
    +    private static func isPortableTail(_ scalar: UnicodeScalar) -> Bool {
    +        let value = scalar.value
    +        return self.isPortableHead(scalar) || (48...57).contains(value)
    +    }
    +
    +    private static func normalizeOverrideKey(_ rawKey: String) -> String? {
    +        let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
    +        guard !key.isEmpty else { return nil }
    +        guard let first = key.unicodeScalars.first, self.isPortableHead(first) else {
    +            return nil
    +        }
    +        for scalar in key.unicodeScalars.dropFirst() {
    +            if self.isPortableTail(scalar) || scalar == "(" || scalar == ")" {
    +                continue
    +            }
    +            return nil
    +        }
    +        return key
    +    }
    +
    +    private static func sortedUnique(_ values: [String]) -> [String] {
    +        Array(Set(values)).sorted()
    +    }
    +
    +    static func inspectOverrides(
    +        overrides: [String: String]?,
    +        blockPathOverrides: Bool = true) -> HostEnvOverrideDiagnostics
    +    {
    +        guard let overrides else {
    +            return HostEnvOverrideDiagnostics(blockedKeys: [], invalidKeys: [])
    +        }
    +
    +        var blocked: [String] = []
    +        var invalid: [String] = []
    +        for (rawKey, _) in overrides {
    +            let candidate = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
    +            guard let normalized = self.normalizeOverrideKey(rawKey) else {
    +                invalid.append(candidate.isEmpty ? rawKey : candidate)
    +                continue
    +            }
    +            let upper = normalized.uppercased()
    +            if blockPathOverrides, upper == "PATH" {
    +                blocked.append(upper)
    +                continue
    +            }
    +            if self.isBlockedOverride(upper) || self.isBlocked(upper) {
    +                blocked.append(upper)
    +                continue
    +            }
    +        }
    +
    +        return HostEnvOverrideDiagnostics(
    +            blockedKeys: self.sortedUnique(blocked),
    +            invalidKeys: self.sortedUnique(invalid))
    +    }
    +
         static func sanitize(overrides: [String: String]?, shellWrapper: Bool = false) -> [String: String] {
             var merged: [String: String] = [:]
             for (rawKey, value) in ProcessInfo.processInfo.environment {
    @@ -57,8 +123,7 @@ enum HostEnvSanitizer {
     
             guard let effectiveOverrides else { return merged }
             for (rawKey, value) in effectiveOverrides {
    -            let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
    -            guard !key.isEmpty else { continue }
    +            guard let key = self.normalizeOverrideKey(rawKey) 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.
    
  • apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift+17 1 modified
    @@ -63,7 +63,23 @@ enum HostEnvSecurityPolicy {
             "OPENSSL_ENGINES",
             "PYTHONSTARTUP",
             "WGETRC",
    -        "CURL_HOME"
    +        "CURL_HOME",
    +        "CLASSPATH",
    +        "CGO_CFLAGS",
    +        "CGO_LDFLAGS",
    +        "GOFLAGS",
    +        "CORECLR_PROFILER_PATH",
    +        "PHPRC",
    +        "PHP_INI_SCAN_DIR",
    +        "DENO_DIR",
    +        "BUN_CONFIG_REGISTRY",
    +        "LUA_PATH",
    +        "LUA_CPATH",
    +        "GEM_HOME",
    +        "GEM_PATH",
    +        "BUNDLE_GEMFILE",
    +        "COMPOSER_HOME",
    +        "XDG_CONFIG_HOME"
         ]
     
         static let blockedOverridePrefixes: [String] = [
    
  • apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift+17 0 modified
    @@ -465,6 +465,23 @@ actor MacNodeRuntime {
                 ? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
                 : self.mainSessionKey
             let runId = UUID().uuidString
    +        let envOverrideDiagnostics = HostEnvSanitizer.inspectOverrides(
    +            overrides: params.env,
    +            blockPathOverrides: true)
    +        if !envOverrideDiagnostics.blockedKeys.isEmpty || !envOverrideDiagnostics.invalidKeys.isEmpty {
    +            var details: [String] = []
    +            if !envOverrideDiagnostics.blockedKeys.isEmpty {
    +                details.append("blocked override keys: \(envOverrideDiagnostics.blockedKeys.joined(separator: ", "))")
    +            }
    +            if !envOverrideDiagnostics.invalidKeys.isEmpty {
    +                details.append(
    +                    "invalid non-portable override keys: \(envOverrideDiagnostics.invalidKeys.joined(separator: ", "))")
    +            }
    +            return Self.errorResponse(
    +                req,
    +                code: .invalidRequest,
    +                message: "SYSTEM_RUN_DENIED: environment override rejected (\(details.joined(separator: "; ")))")
    +        }
             let evaluation = await ExecApprovalEvaluator.evaluate(
                 command: command,
                 rawCommand: params.rawCommand,
    
  • apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift+20 0 modified
    @@ -33,4 +33,24 @@ struct HostEnvSanitizerTests {
             let env = HostEnvSanitizer.sanitize(overrides: ["OPENCLAW_TOKEN": "secret"])
             #expect(env["OPENCLAW_TOKEN"] == "secret")
         }
    +
    +    @Test func `inspect overrides rejects blocked and invalid keys`() {
    +        let diagnostics = HostEnvSanitizer.inspectOverrides(overrides: [
    +            "CLASSPATH": "/tmp/evil-classpath",
    +            "BAD-KEY": "x",
    +            "ProgramFiles(x86)": "C:\\Program Files (x86)",
    +        ])
    +
    +        #expect(diagnostics.blockedKeys == ["CLASSPATH"])
    +        #expect(diagnostics.invalidKeys == ["BAD-KEY"])
    +    }
    +
    +    @Test func `sanitize accepts Windows-style override key names`() {
    +        let env = HostEnvSanitizer.sanitize(overrides: [
    +            "ProgramFiles(x86)": "D:\\SDKs",
    +            "CommonProgramFiles(x86)": "D:\\Common",
    +        ])
    +        #expect(env["ProgramFiles(x86)"] == "D:\\SDKs")
    +        #expect(env["CommonProgramFiles(x86)"] == "D:\\Common")
    +    }
     }
    
  • apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift+26 0 modified
    @@ -21,6 +21,32 @@ struct MacNodeRuntimeTests {
             #expect(response.ok == false)
         }
     
    +    @Test func `handle invoke rejects blocked system run env override before execution`() async throws {
    +        let runtime = MacNodeRuntime()
    +        let params = OpenClawSystemRunParams(
    +            command: ["/bin/sh", "-lc", "echo ok"],
    +            env: ["CLASSPATH": "/tmp/evil-classpath"])
    +        let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
    +        let response = await runtime.handleInvoke(
    +            BridgeInvokeRequest(id: "req-2c", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
    +        #expect(response.ok == false)
    +        #expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true)
    +        #expect(response.error?.message.contains("CLASSPATH") == true)
    +    }
    +
    +    @Test func `handle invoke rejects invalid system run env override key before execution`() async throws {
    +        let runtime = MacNodeRuntime()
    +        let params = OpenClawSystemRunParams(
    +            command: ["/bin/sh", "-lc", "echo ok"],
    +            env: ["BAD-KEY": "x"])
    +        let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
    +        let response = await runtime.handleInvoke(
    +            BridgeInvokeRequest(id: "req-2d", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
    +        #expect(response.ok == false)
    +        #expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true)
    +        #expect(response.error?.message.contains("BAD-KEY") == true)
    +    }
    +
         @Test func `handle invoke rejects empty system which`() async throws {
             let runtime = MacNodeRuntime()
             let params = OpenClawSystemWhichParams(bins: [])
    
  • CHANGELOG.md+1 0 modified
    @@ -153,6 +153,7 @@ Docs: https://docs.openclaw.ai
     - Hardening: refresh stale device pairing requests and pending metadata (#50695) Thanks @smaeljaish771 and @joshavant.
     - Gateway: harden OpenResponses file-context escaping (#50782) Thanks @YLChen-007 and @joshavant.
     - LINE: harden Express webhook parsing to verified raw body (#51202) Thanks @gladiator9797 and @joshavant.
    +- Exec: harden host env override handling across gateway and node (#51207) Thanks @gladiator9797 and @joshavant.
     - xAI/models: rename the bundled Grok 4.20 catalog entries to the GA IDs and normalize saved deprecated beta IDs at runtime so existing configs and sessions keep resolving. (#50772) thanks @Jaaneek
     
     ### Fixes
    
  • src/agents/bash-tools.exec.path.test.ts+16 0 modified
    @@ -130,6 +130,22 @@ describe("exec PATH login shell merge", () => {
         expect(shellPathMock).not.toHaveBeenCalled();
       });
     
    +  it("fails closed when a blocked runtime override key is requested", async () => {
    +    if (isWin) {
    +      return;
    +    }
    +    const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
    +
    +    await expect(
    +      tool.execute("call-blocked-runtime-env", {
    +        command: "echo ok",
    +        env: { CLASSPATH: "/tmp/evil-classpath" },
    +      }),
    +    ).rejects.toThrow(
    +      /Security Violation: Environment variable 'CLASSPATH' is forbidden during host execution\./,
    +    );
    +  });
    +
       it("does not apply login-shell PATH when probe rejects unregistered absolute SHELL", async () => {
         if (isWin) {
           return;
    
  • src/agents/bash-tools.exec.ts+51 18 modified
    @@ -3,6 +3,7 @@ import path from "node:path";
     import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
     import { type ExecHost, loadExecApprovals, maxAsk, minSecurity } from "../infra/exec-approvals.js";
     import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
    +import { sanitizeHostExecEnvWithDiagnostics } from "../infra/host-env-security.js";
     import {
       getShellPathFromLoginShell,
       resolveShellEnvFallbackTimeoutMs,
    @@ -25,9 +26,7 @@ import {
       renderExecHostLabel,
       resolveApprovalRunningNoticeMs,
       runExecProcess,
    -  sanitizeHostBaseEnv,
       execSchema,
    -  validateHostEnv,
     } from "./bash-tools.exec-runtime.js";
     import type {
       ExecElevatedDefaults,
    @@ -362,24 +361,58 @@ export function createExecTool(
           }
     
           const inheritedBaseEnv = coerceEnv(process.env);
    -      const baseEnv = host === "sandbox" ? inheritedBaseEnv : sanitizeHostBaseEnv(inheritedBaseEnv);
    -
    -      // Logic: Sandbox gets raw env. Host (gateway/node) must pass validation.
    -      // We validate BEFORE merging to prevent any dangerous vars from entering the stream.
    -      if (host !== "sandbox" && params.env) {
    -        validateHostEnv(params.env);
    +      const hostEnvResult =
    +        host === "sandbox"
    +          ? null
    +          : sanitizeHostExecEnvWithDiagnostics({
    +              baseEnv: inheritedBaseEnv,
    +              overrides: params.env,
    +              blockPathOverrides: true,
    +            });
    +      if (
    +        hostEnvResult &&
    +        params.env &&
    +        (hostEnvResult.rejectedOverrideBlockedKeys.length > 0 ||
    +          hostEnvResult.rejectedOverrideInvalidKeys.length > 0)
    +      ) {
    +        const blockedKeys = hostEnvResult.rejectedOverrideBlockedKeys;
    +        const invalidKeys = hostEnvResult.rejectedOverrideInvalidKeys;
    +        const pathBlocked = blockedKeys.includes("PATH");
    +        if (pathBlocked && blockedKeys.length === 1 && invalidKeys.length === 0) {
    +          throw new Error(
    +            "Security Violation: Custom 'PATH' variable is forbidden during host execution.",
    +          );
    +        }
    +        if (blockedKeys.length === 1 && invalidKeys.length === 0) {
    +          throw new Error(
    +            `Security Violation: Environment variable '${blockedKeys[0]}' is forbidden during host execution.`,
    +          );
    +        }
    +        const details: string[] = [];
    +        if (blockedKeys.length > 0) {
    +          details.push(`blocked override keys: ${blockedKeys.join(", ")}`);
    +        }
    +        if (invalidKeys.length > 0) {
    +          details.push(`invalid non-portable override keys: ${invalidKeys.join(", ")}`);
    +        }
    +        const suffix = details.join("; ");
    +        if (pathBlocked) {
    +          throw new Error(
    +            `Security Violation: Custom 'PATH' variable is forbidden during host execution (${suffix}).`,
    +          );
    +        }
    +        throw new Error(`Security Violation: ${suffix}.`);
           }
     
    -      const mergedEnv = params.env ? { ...baseEnv, ...params.env } : baseEnv;
    -
    -      const env = sandbox
    -        ? buildSandboxEnv({
    -            defaultPath: DEFAULT_PATH,
    -            paramsEnv: params.env,
    -            sandboxEnv: sandbox.env,
    -            containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
    -          })
    -        : mergedEnv;
    +      const env =
    +        sandbox && host === "sandbox"
    +          ? buildSandboxEnv({
    +              defaultPath: DEFAULT_PATH,
    +              paramsEnv: params.env,
    +              sandboxEnv: sandbox.env,
    +              containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
    +            })
    +          : (hostEnvResult?.env ?? inheritedBaseEnv);
     
           if (!sandbox && host === "gateway" && !params.env?.PATH) {
             const shellPath = getShellPathFromLoginShell({
    
  • src/infra/host-env-security-policy.json+17 1 modified
    @@ -56,7 +56,23 @@
         "OPENSSL_ENGINES",
         "PYTHONSTARTUP",
         "WGETRC",
    -    "CURL_HOME"
    +    "CURL_HOME",
    +    "CLASSPATH",
    +    "CGO_CFLAGS",
    +    "CGO_LDFLAGS",
    +    "GOFLAGS",
    +    "CORECLR_PROFILER_PATH",
    +    "PHPRC",
    +    "PHP_INI_SCAN_DIR",
    +    "DENO_DIR",
    +    "BUN_CONFIG_REGISTRY",
    +    "LUA_PATH",
    +    "LUA_CPATH",
    +    "GEM_HOME",
    +    "GEM_PATH",
    +    "BUNDLE_GEMFILE",
    +    "COMPOSER_HOME",
    +    "XDG_CONFIG_HOME"
       ],
       "blockedOverridePrefixes": ["GIT_CONFIG_", "NPM_CONFIG_"],
       "blockedPrefixes": ["DYLD_", "LD_", "BASH_FUNC_"]
    
  • src/infra/host-env-security.test.ts+60 1 modified
    @@ -8,6 +8,7 @@ import {
       isDangerousHostEnvVarName,
       normalizeEnvVarKey,
       sanitizeHostExecEnv,
    +  sanitizeHostExecEnvWithDiagnostics,
       sanitizeSystemRunEnvOverrides,
     } from "./host-env-security.js";
     import { OPENCLAW_CLI_ENV_VALUE } from "./openclaw-exec-env.js";
    @@ -114,6 +115,10 @@ describe("sanitizeHostExecEnv", () => {
             GIT_CONFIG_GLOBAL: "/tmp/gitconfig",
             SHELLOPTS: "xtrace",
             PS4: "$(touch /tmp/pwned)",
    +        CLASSPATH: "/tmp/evil-classpath",
    +        GOFLAGS: "-mod=mod",
    +        PHPRC: "/tmp/evil-php.ini",
    +        XDG_CONFIG_HOME: "/tmp/evil-config",
             SAFE: "ok",
           },
         });
    @@ -128,6 +133,10 @@ describe("sanitizeHostExecEnv", () => {
         expect(env.GIT_CONFIG_GLOBAL).toBeUndefined();
         expect(env.SHELLOPTS).toBeUndefined();
         expect(env.PS4).toBeUndefined();
    +    expect(env.CLASSPATH).toBeUndefined();
    +    expect(env.GOFLAGS).toBeUndefined();
    +    expect(env.PHPRC).toBeUndefined();
    +    expect(env.XDG_CONFIG_HOME).toBeUndefined();
         expect(env.SAFE).toBe("ok");
         expect(env.HOME).toBe("/tmp/trusted-home");
         expect(env.ZDOTDIR).toBe("/tmp/trusted-zdotdir");
    @@ -183,21 +192,24 @@ describe("sanitizeHostExecEnv", () => {
         expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE);
       });
     
    -  it("drops non-string inherited values and non-portable inherited keys", () => {
    +  it("drops non-string inherited values while preserving non-portable inherited keys", () => {
         const env = sanitizeHostExecEnv({
           baseEnv: {
             PATH: "/usr/bin:/bin",
             GOOD: "1",
             // oxlint-disable-next-line typescript/no-explicit-any
             BAD_NUMBER: 1 as any,
             "NOT-PORTABLE": "x",
    +        "ProgramFiles(x86)": "C:\\Program Files (x86)",
           },
         });
     
         expect(env).toEqual({
           OPENCLAW_CLI: OPENCLAW_CLI_ENV_VALUE,
           PATH: "/usr/bin:/bin",
           GOOD: "1",
    +      "NOT-PORTABLE": "x",
    +      "ProgramFiles(x86)": "C:\\Program Files (x86)",
         });
       });
     });
    @@ -212,11 +224,58 @@ describe("isDangerousHostEnvOverrideVarName", () => {
         expect(isDangerousHostEnvOverrideVarName("git_config_global")).toBe(true);
         expect(isDangerousHostEnvOverrideVarName("GRADLE_USER_HOME")).toBe(true);
         expect(isDangerousHostEnvOverrideVarName("gradle_user_home")).toBe(true);
    +    expect(isDangerousHostEnvOverrideVarName("CLASSPATH")).toBe(true);
    +    expect(isDangerousHostEnvOverrideVarName("classpath")).toBe(true);
    +    expect(isDangerousHostEnvOverrideVarName("GOFLAGS")).toBe(true);
    +    expect(isDangerousHostEnvOverrideVarName("goflags")).toBe(true);
    +    expect(isDangerousHostEnvOverrideVarName("CORECLR_PROFILER_PATH")).toBe(true);
    +    expect(isDangerousHostEnvOverrideVarName("coreclr_profiler_path")).toBe(true);
    +    expect(isDangerousHostEnvOverrideVarName("XDG_CONFIG_HOME")).toBe(true);
    +    expect(isDangerousHostEnvOverrideVarName("xdg_config_home")).toBe(true);
         expect(isDangerousHostEnvOverrideVarName("BASH_ENV")).toBe(false);
         expect(isDangerousHostEnvOverrideVarName("FOO")).toBe(false);
       });
     });
     
    +describe("sanitizeHostExecEnvWithDiagnostics", () => {
    +  it("reports blocked and invalid requested overrides", () => {
    +    const result = sanitizeHostExecEnvWithDiagnostics({
    +      baseEnv: {
    +        PATH: "/usr/bin:/bin",
    +      },
    +      overrides: {
    +        PATH: "/tmp/evil",
    +        CLASSPATH: "/tmp/evil-classpath",
    +        SAFE_KEY: "ok",
    +        "BAD-KEY": "bad",
    +      },
    +    });
    +
    +    expect(result.rejectedOverrideBlockedKeys).toEqual(["CLASSPATH", "PATH"]);
    +    expect(result.rejectedOverrideInvalidKeys).toEqual(["BAD-KEY"]);
    +    expect(result.env.SAFE_KEY).toBe("ok");
    +    expect(result.env.PATH).toBe("/usr/bin:/bin");
    +    expect(result.env.CLASSPATH).toBeUndefined();
    +  });
    +
    +  it("allows Windows-style override names while still rejecting invalid keys", () => {
    +    const result = sanitizeHostExecEnvWithDiagnostics({
    +      baseEnv: {
    +        PATH: "/usr/bin:/bin",
    +        "ProgramFiles(x86)": "C:\\Program Files (x86)",
    +      },
    +      overrides: {
    +        "ProgramFiles(x86)": "D:\\SDKs",
    +        "BAD-KEY": "bad",
    +      },
    +    });
    +
    +    expect(result.rejectedOverrideBlockedKeys).toEqual([]);
    +    expect(result.rejectedOverrideInvalidKeys).toEqual(["BAD-KEY"]);
    +    expect(result.env["ProgramFiles(x86)"]).toBe("D:\\SDKs");
    +  });
    +});
    +
     describe("normalizeEnvVarKey", () => {
       it("normalizes and validates keys", () => {
         expect(normalizeEnvVarKey(" OPENROUTER_API_KEY ")).toBe("OPENROUTER_API_KEY");
    
  • src/infra/host-env-security.ts+115 20 modified
    @@ -2,6 +2,7 @@ import HOST_ENV_SECURITY_POLICY_JSON from "./host-env-security-policy.json" with
     import { markOpenClawExecEnv } from "./openclaw-exec-env.js";
     
     const PORTABLE_ENV_VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
    +const WINDOWS_COMPAT_OVERRIDE_ENV_VAR_KEY = /^[A-Za-z_][A-Za-z0-9_()]*$/;
     
     type HostEnvSecurityPolicy = {
       blockedKeys: string[];
    @@ -42,6 +43,17 @@ export const HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEYS = new Set<string>(
       HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEY_VALUES,
     );
     
    +export type HostExecEnvSanitizationResult = {
    +  env: Record<string, string>;
    +  rejectedOverrideBlockedKeys: string[];
    +  rejectedOverrideInvalidKeys: string[];
    +};
    +
    +export type HostExecEnvOverrideDiagnostics = {
    +  rejectedOverrideBlockedKeys: string[];
    +  rejectedOverrideInvalidKeys: string[];
    +};
    +
     export function normalizeEnvVarKey(
       rawKey: string,
       options?: { portable?: boolean },
    @@ -56,6 +68,17 @@ export function normalizeEnvVarKey(
       return key;
     }
     
    +function normalizeHostOverrideEnvVarKey(rawKey: string): string | null {
    +  const key = normalizeEnvVarKey(rawKey);
    +  if (!key) {
    +    return null;
    +  }
    +  if (PORTABLE_ENV_VAR_KEY.test(key) || WINDOWS_COMPAT_OVERRIDE_ENV_VAR_KEY.test(key)) {
    +    return key;
    +  }
    +  return null;
    +}
    +
     export function isDangerousHostEnvVarName(rawKey: string): boolean {
       const key = normalizeEnvVarKey(rawKey);
       if (!key) {
    @@ -80,15 +103,16 @@ export function isDangerousHostEnvOverrideVarName(rawKey: string): boolean {
       return HOST_DANGEROUS_OVERRIDE_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix));
     }
     
    -function listNormalizedPortableEnvEntries(
    +function listNormalizedEnvEntries(
       source: Record<string, string | undefined>,
    +  options?: { portable?: boolean },
     ): Array<[string, string]> {
       const entries: Array<[string, string]> = [];
       for (const [rawKey, value] of Object.entries(source)) {
         if (typeof value !== "string") {
           continue;
         }
    -    const key = normalizeEnvVarKey(rawKey, { portable: true });
    +    const key = normalizeEnvVarKey(rawKey, options);
         if (!key) {
           continue;
         }
    @@ -97,41 +121,112 @@ function listNormalizedPortableEnvEntries(
       return entries;
     }
     
    -export function sanitizeHostExecEnv(params?: {
    -  baseEnv?: Record<string, string | undefined>;
    +function sortUnique(values: Iterable<string>): string[] {
    +  return Array.from(new Set(values)).toSorted((a, b) => a.localeCompare(b));
    +}
    +
    +function sanitizeHostEnvOverridesWithDiagnostics(params?: {
       overrides?: Record<string, string> | null;
       blockPathOverrides?: boolean;
    -}): Record<string, string> {
    -  const baseEnv = params?.baseEnv ?? process.env;
    +}): {
    +  acceptedOverrides?: Record<string, string>;
    +  rejectedOverrideBlockedKeys: string[];
    +  rejectedOverrideInvalidKeys: string[];
    +} {
       const overrides = params?.overrides ?? undefined;
    +  if (!overrides) {
    +    return {
    +      acceptedOverrides: undefined,
    +      rejectedOverrideBlockedKeys: [],
    +      rejectedOverrideInvalidKeys: [],
    +    };
    +  }
    +
       const blockPathOverrides = params?.blockPathOverrides ?? true;
    +  const acceptedOverrides: Record<string, string> = {};
    +  const rejectedBlocked: string[] = [];
    +  const rejectedInvalid: string[] = [];
     
    -  const merged: Record<string, string> = {};
    -  for (const [key, value] of listNormalizedPortableEnvEntries(baseEnv)) {
    -    if (isDangerousHostEnvVarName(key)) {
    +  for (const [rawKey, value] of Object.entries(overrides)) {
    +    if (typeof value !== "string") {
           continue;
         }
    -    merged[key] = value;
    -  }
    -
    -  if (!overrides) {
    -    return markOpenClawExecEnv(merged);
    -  }
    -
    -  for (const [key, value] of listNormalizedPortableEnvEntries(overrides)) {
    -    const upper = key.toUpperCase();
    +    const normalized = normalizeHostOverrideEnvVarKey(rawKey);
    +    if (!normalized) {
    +      const candidate = rawKey.trim();
    +      rejectedInvalid.push(candidate || rawKey);
    +      continue;
    +    }
    +    const upper = normalized.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") {
    +      rejectedBlocked.push(upper);
           continue;
         }
         if (isDangerousHostEnvVarName(upper) || isDangerousHostEnvOverrideVarName(upper)) {
    +      rejectedBlocked.push(upper);
    +      continue;
    +    }
    +    acceptedOverrides[normalized] = value;
    +  }
    +
    +  return {
    +    acceptedOverrides,
    +    rejectedOverrideBlockedKeys: sortUnique(rejectedBlocked),
    +    rejectedOverrideInvalidKeys: sortUnique(rejectedInvalid),
    +  };
    +}
    +
    +export function sanitizeHostExecEnvWithDiagnostics(params?: {
    +  baseEnv?: Record<string, string | undefined>;
    +  overrides?: Record<string, string> | null;
    +  blockPathOverrides?: boolean;
    +}): HostExecEnvSanitizationResult {
    +  const baseEnv = params?.baseEnv ?? process.env;
    +
    +  const merged: Record<string, string> = {};
    +  for (const [key, value] of listNormalizedEnvEntries(baseEnv)) {
    +    if (isDangerousHostEnvVarName(key)) {
           continue;
         }
         merged[key] = value;
       }
     
    -  return markOpenClawExecEnv(merged);
    +  const overrideResult = sanitizeHostEnvOverridesWithDiagnostics({
    +    overrides: params?.overrides ?? undefined,
    +    blockPathOverrides: params?.blockPathOverrides ?? true,
    +  });
    +  if (overrideResult.acceptedOverrides) {
    +    for (const [key, value] of Object.entries(overrideResult.acceptedOverrides)) {
    +      merged[key] = value;
    +    }
    +  }
    +
    +  return {
    +    env: markOpenClawExecEnv(merged),
    +    rejectedOverrideBlockedKeys: overrideResult.rejectedOverrideBlockedKeys,
    +    rejectedOverrideInvalidKeys: overrideResult.rejectedOverrideInvalidKeys,
    +  };
    +}
    +
    +export function inspectHostExecEnvOverrides(params?: {
    +  overrides?: Record<string, string> | null;
    +  blockPathOverrides?: boolean;
    +}): HostExecEnvOverrideDiagnostics {
    +  const result = sanitizeHostEnvOverridesWithDiagnostics(params);
    +  return {
    +    rejectedOverrideBlockedKeys: result.rejectedOverrideBlockedKeys,
    +    rejectedOverrideInvalidKeys: result.rejectedOverrideInvalidKeys,
    +  };
    +}
    +
    +export function sanitizeHostExecEnv(params?: {
    +  baseEnv?: Record<string, string | undefined>;
    +  overrides?: Record<string, string> | null;
    +  blockPathOverrides?: boolean;
    +}): Record<string, string> {
    +  return sanitizeHostExecEnvWithDiagnostics(params).env;
     }
     
     export function sanitizeSystemRunEnvOverrides(params?: {
    @@ -146,7 +241,7 @@ export function sanitizeSystemRunEnvOverrides(params?: {
         return overrides;
       }
       const filtered: Record<string, string> = {};
    -  for (const [key, value] of listNormalizedPortableEnvEntries(overrides)) {
    +  for (const [key, value] of listNormalizedEnvEntries(overrides, { portable: true })) {
         if (!HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEYS.has(key.toUpperCase())) {
           continue;
         }
    
  • src/node-host/invoke.sanitize-env.test.ts+7 0 modified
    @@ -51,6 +51,13 @@ describe("node-host sanitizeEnv", () => {
           expect(env.BASH_ENV).toBeUndefined();
         });
       });
    +
    +  it("preserves inherited non-portable Windows-style env keys", () => {
    +    withEnv({ "ProgramFiles(x86)": "C:\\Program Files (x86)" }, () => {
    +      const env = sanitizeEnv(undefined);
    +      expect(env["ProgramFiles(x86)"]).toBe("C:\\Program Files (x86)");
    +    });
    +  });
     });
     
     describe("node-host output decoding", () => {
    
  • src/node-host/invoke-system-run.test.ts+61 0 modified
    @@ -336,6 +336,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
         preferMacAppExecHost: boolean;
         runViaResponse?: ExecHostResponse | null;
         command?: string[];
    +    env?: Record<string, string>;
         rawCommand?: string | null;
         systemRunPlan?: SystemRunApprovalPlan | null;
         cwd?: string;
    @@ -391,6 +392,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
           client: {} as never,
           params: {
             command: params.command ?? ["echo", "ok"],
    +        env: params.env,
             rawCommand: params.rawCommand,
             systemRunPlan: params.systemRunPlan,
             cwd: params.cwd,
    @@ -1106,6 +1108,65 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
         expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult });
       });
     
    +  it("rejects blocked environment overrides before execution", async () => {
    +    const { runCommand, sendInvokeResult } = await runSystemInvoke({
    +      preferMacAppExecHost: false,
    +      security: "full",
    +      ask: "off",
    +      env: { CLASSPATH: "/tmp/evil-classpath" },
    +    });
    +
    +    expect(runCommand).not.toHaveBeenCalled();
    +    expectInvokeErrorMessage(sendInvokeResult, {
    +      message: "SYSTEM_RUN_DENIED: environment override rejected",
    +    });
    +    expectInvokeErrorMessage(sendInvokeResult, {
    +      message: "CLASSPATH",
    +    });
    +  });
    +
    +  it("rejects blocked environment overrides for shell-wrapper commands", async () => {
    +    const shellCommand =
    +      process.platform === "win32"
    +        ? ["cmd.exe", "/d", "/s", "/c", "echo ok"]
    +        : ["/bin/sh", "-lc", "echo ok"];
    +    const { runCommand, sendInvokeResult } = await runSystemInvoke({
    +      preferMacAppExecHost: false,
    +      security: "full",
    +      ask: "off",
    +      command: shellCommand,
    +      env: {
    +        CLASSPATH: "/tmp/evil-classpath",
    +        LANG: "C",
    +      },
    +    });
    +
    +    expect(runCommand).not.toHaveBeenCalled();
    +    expectInvokeErrorMessage(sendInvokeResult, {
    +      message: "SYSTEM_RUN_DENIED: environment override rejected",
    +    });
    +    expectInvokeErrorMessage(sendInvokeResult, {
    +      message: "CLASSPATH",
    +    });
    +  });
    +
    +  it("rejects invalid non-portable environment override keys before execution", async () => {
    +    const { runCommand, sendInvokeResult } = await runSystemInvoke({
    +      preferMacAppExecHost: false,
    +      security: "full",
    +      ask: "off",
    +      env: { "BAD-KEY": "x" },
    +    });
    +
    +    expect(runCommand).not.toHaveBeenCalled();
    +    expectInvokeErrorMessage(sendInvokeResult, {
    +      message: "SYSTEM_RUN_DENIED: environment override rejected",
    +    });
    +    expectInvokeErrorMessage(sendInvokeResult, {
    +      message: "BAD-KEY",
    +    });
    +  });
    +
       async function expectNestedEnvShellDenied(params: {
         depth: number;
         markerName: string;
    
  • src/node-host/invoke-system-run.ts+32 1 modified
    @@ -14,7 +14,10 @@ import {
     } from "../infra/exec-approvals.js";
     import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../infra/exec-host.js";
     import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
    -import { sanitizeSystemRunEnvOverrides } from "../infra/host-env-security.js";
    +import {
    +  inspectHostExecEnvOverrides,
    +  sanitizeSystemRunEnvOverrides,
    +} from "../infra/host-env-security.js";
     import { normalizeSystemRunApprovalPlan } from "../infra/system-run-approval-binding.js";
     import { resolveSystemRunCommandRequest } from "../infra/system-run-command.js";
     import { logWarn } from "../logger.js";
    @@ -244,6 +247,34 @@ async function parseSystemRunPhase(
       const sessionKey = opts.params.sessionKey?.trim() || "node";
       const runId = opts.params.runId?.trim() || crypto.randomUUID();
       const suppressNotifyOnExit = opts.params.suppressNotifyOnExit === true;
    +  const envOverrideDiagnostics = inspectHostExecEnvOverrides({
    +    overrides: opts.params.env ?? undefined,
    +    blockPathOverrides: true,
    +  });
    +  if (
    +    envOverrideDiagnostics.rejectedOverrideBlockedKeys.length > 0 ||
    +    envOverrideDiagnostics.rejectedOverrideInvalidKeys.length > 0
    +  ) {
    +    const details: string[] = [];
    +    if (envOverrideDiagnostics.rejectedOverrideBlockedKeys.length > 0) {
    +      details.push(
    +        `blocked override keys: ${envOverrideDiagnostics.rejectedOverrideBlockedKeys.join(", ")}`,
    +      );
    +    }
    +    if (envOverrideDiagnostics.rejectedOverrideInvalidKeys.length > 0) {
    +      details.push(
    +        `invalid non-portable override keys: ${envOverrideDiagnostics.rejectedOverrideInvalidKeys.join(", ")}`,
    +      );
    +    }
    +    await opts.sendInvokeResult({
    +      ok: false,
    +      error: {
    +        code: "INVALID_REQUEST",
    +        message: `SYSTEM_RUN_DENIED: environment override rejected (${details.join("; ")})`,
    +      },
    +    });
    +    return null;
    +  }
       const envOverrides = sanitizeSystemRunEnvOverrides({
         overrides: opts.params.env ?? undefined,
         shellWrapper: shellPayload !== null,
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.