Low severityNVD Advisory· Published Mar 21, 2026· Updated Mar 23, 2026
OpenClaw < 2026.2.26 - Approval Context-Binding Weakness in system.run via host=node
CVE-2026-32058
Description
OpenClaw versions prior to 2026.2.26 contain an approval context-binding weakness in system.run execution flows with host=node that allows reuse of previously approved requests with modified environment variables. Attackers with access to an approval id can exploit this by reusing an approval with changed env input, bypassing execution-integrity controls in approval-enabled workflows.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.26 | 2026.2.26 |
Affected products
1Patches
110481097f8e6refactor(security): enforce v1 node exec approval binding
19 files changed · +446 −183
apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift+1 −1 modified@@ -1,6 +1,6 @@ // Generated file. Do not edit directly. // Source: src/infra/host-env-security-policy.json -// Regenerate: node scripts/generate-host-env-security-policy-swift.mjs +// Regenerate: node scripts/generate-host-env-security-policy-swift.mjs --write import Foundation
CHANGELOG.md+1 −1 modified@@ -24,7 +24,7 @@ Docs: https://docs.openclaw.ai - Security/Sandbox path alias guard: reject broken symlink targets by resolving through existing ancestors and failing closed on out-of-root targets, preventing workspace-only `apply_patch` writes from escaping sandbox/workspace boundaries via dangling symlinks. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. - Security/Workspace FS boundary aliases: harden canonical boundary resolution for non-existent-leaf symlink aliases while preserving valid in-root aliases, preventing first-write workspace escapes via out-of-root symlink targets. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. - Security/Config includes: harden `$include` file loading with verified-open reads, reject hardlinked include aliases, and enforce include file-size guardrails so config include resolution remains bounded to trusted in-root files. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting. -- Security/Node exec approvals: bind `system.run` approvals to canonicalized env overrides (`envHash`/`envKeys`) and fail closed on env-binding mismatches/missing bindings, while adding `GIT_EXTERNAL_DIFF` to blocked host env keys. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Node exec approvals: require structured `commandArgv` approvals for `host=node`, enforce versioned `systemRunBindingV1` matching for argv/cwd/session/agent/env context with fail-closed behavior on missing/mismatched bindings, and add `GIT_EXTERNAL_DIFF` to blocked host env keys. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. - Security/Microsoft Teams media fetch: route Graph message/hosted-content/attachment fetches and auth-scope fallback attachment downloads through shared SSRF-guarded fetch paths, and centralize hostname-suffix allowlist policy helpers in the plugin SDK to remove channel/plugin drift. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. - Microsoft Teams/File uploads: acknowledge `fileConsent/invoke` immediately (`invokeResponse` before upload + file card send) so Teams no longer shows false "Something went wrong" timeout banners while upload completion continues asynchronously; includes updated async regression coverage. Landed from contributor PR #27641 by @scz2011. - Security/Plugin channel HTTP auth: normalize protected `/api/channels` path checks against canonicalized request paths (case + percent-decoding + slash normalization), resolve encoded dot-segment traversal variants, and fail closed on malformed `%`-encoded channel prefixes so alternate-path variants cannot bypass gateway auth. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting.
package.json+5 −1 modified@@ -54,8 +54,9 @@ "build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:auth:no-pairing-store-group", + "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:auth:no-pairing-store-group && pnpm check:host-env-policy:swift", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links", + "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", "deadcode:ci": "pnpm deadcode:report:ci:knip && pnpm deadcode:report:ci:ts-prune && pnpm deadcode:report:ci:ts-unused", "deadcode:knip": "pnpm dlx knip --no-progress", @@ -83,6 +84,8 @@ "gateway:dev": "OPENCLAW_SKIP_CHANNELS=1 CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway", "gateway:dev:reset": "OPENCLAW_SKIP_CHANNELS=1 CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway --reset", "gateway:watch": "node scripts/watch-node.mjs gateway --force", + "gen:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --write", + "ghsa:patch": "node scripts/ghsa-patch.mjs", "ios:build": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'", "ios:gen": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate'", "ios:open": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'", @@ -133,6 +136,7 @@ "test:install:smoke": "bash scripts/test-install-sh-docker.sh", "test:live": "OPENCLAW_LIVE_TEST=1 CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts", "test:macmini": "OPENCLAW_TEST_VM_FORKS=0 OPENCLAW_TEST_PROFILE=serial node scripts/test-parallel.mjs", + "test:sectriage": "pnpm exec vitest run --config vitest.gateway.config.ts && vitest run --config vitest.unit.config.ts --exclude src/daemon/launchd.integration.test.ts --exclude src/process/exec.test.ts", "test:ui": "pnpm lint:ui:no-raw-window-open && pnpm --dir ui test", "test:voicecall:closedloop": "vitest run extensions/voice-call/src/manager.test.ts extensions/voice-call/src/media-stream.test.ts src/plugins/voice-call.plugin.test.ts --maxWorkers=1", "test:watch": "vitest",
scripts/generate-host-env-security-policy-swift.mjs+33 −4 modified@@ -3,6 +3,15 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +const args = new Set(process.argv.slice(2)); +const checkOnly = args.has("--check"); +const writeMode = args.has("--write") || !checkOnly; + +if (checkOnly && args.has("--write")) { + console.error("Use either --check or --write, not both."); + process.exit(1); +} + const here = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(here, ".."); const policyPath = path.join(repoRoot, "src", "infra", "host-env-security-policy.json"); @@ -20,9 +29,9 @@ const policy = JSON.parse(fs.readFileSync(policyPath, "utf8")); const renderSwiftStringArray = (items) => items.map((item) => ` "${item}"`).join(",\n"); -const swift = `// Generated file. Do not edit directly. +const generated = `// Generated file. Do not edit directly. // Source: src/infra/host-env-security-policy.json -// Regenerate: node scripts/generate-host-env-security-policy-swift.mjs +// Regenerate: node scripts/generate-host-env-security-policy-swift.mjs --write import Foundation @@ -41,5 +50,25 @@ ${renderSwiftStringArray(policy.blockedPrefixes)} } `; -fs.writeFileSync(outputPath, swift); -console.log(`Wrote ${path.relative(repoRoot, outputPath)}`); +const current = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, "utf8") : null; + +if (checkOnly) { + if (current === generated) { + console.log(`OK ${path.relative(repoRoot, outputPath)}`); + process.exit(0); + } + console.error( + [ + `Out of date ${path.relative(repoRoot, outputPath)}.`, + "Run: node scripts/generate-host-env-security-policy-swift.mjs --write", + ].join("\n"), + ); + process.exit(1); +} + +if (writeMode) { + if (current !== generated) { + fs.writeFileSync(outputPath, generated); + } + console.log(`Wrote ${path.relative(repoRoot, outputPath)}`); +}
scripts/ghsa-patch.mjs+168 −0 added@@ -0,0 +1,168 @@ +#!/usr/bin/env node +import { execFileSync, spawnSync } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +function usage() { + console.error( + [ + "Usage:", + " node scripts/ghsa-patch.mjs --ghsa <GHSA-id-or-url> [--repo owner/name]", + " --summary <text> --severity <low|medium|high|critical>", + " --description-file <path>", + " --vulnerable-version-range <range>", + " --patched-versions <range-or-null>", + " [--package openclaw] [--ecosystem npm] [--cvss <vector>]", + ].join("\n"), + ); +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith("--")) { + fail(`Unexpected argument: ${arg}`); + } + const key = arg.slice(2); + const value = argv[i + 1]; + if (!value || value.startsWith("--")) { + fail(`Missing value for --${key}`); + } + out[key] = value; + i += 1; + } + return out; +} + +function runGh(args) { + const proc = spawnSync("gh", args, { encoding: "utf8" }); + if (proc.status !== 0) { + fail(proc.stderr.trim() || proc.stdout.trim() || `gh ${args.join(" ")} failed`); + } + return proc.stdout; +} + +function deriveRepoFromOrigin() { + const remote = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" }).trim(); + const httpsMatch = remote.match(/github\.com[/:]([^/]+)\/([^/.]+)(?:\.git)?$/); + if (!httpsMatch) { + fail(`Could not parse origin remote: ${remote}`); + } + return `${httpsMatch[1]}/${httpsMatch[2]}`; +} + +function parseGhsaId(value) { + const match = value.match(/GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}/i); + if (!match) { + fail(`Could not parse GHSA id from: ${value}`); + } + return match[0]; +} + +function writeTempJson(data) { + const file = path.join(os.tmpdir(), `ghsa-patch-${crypto.randomUUID()}.json`); + fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`); + return file; +} + +const args = parseArgs(process.argv.slice(2)); +if (!args.ghsa || !args.summary || !args.severity || !args["description-file"]) { + usage(); + process.exit(1); +} + +const repo = args.repo || deriveRepoFromOrigin(); +const ghsaId = parseGhsaId(args.ghsa); +const advisoryPath = `/repos/${repo}/security-advisories/${ghsaId}`; +const descriptionPath = path.resolve(args["description-file"]); + +if (!fs.existsSync(descriptionPath)) { + fail(`Description file does not exist: ${descriptionPath}`); +} + +const current = JSON.parse(runGh(["api", "-H", "X-GitHub-Api-Version: 2022-11-28", advisoryPath])); +const restoredCvss = args.cvss || current?.cvss?.vector_string || null; + +const ecosystem = args.ecosystem || "npm"; +const packageName = args.package || "openclaw"; +const vulnerableRange = args["vulnerable-version-range"]; +const patchedVersionsRaw = args["patched-versions"]; + +if (!vulnerableRange) { + fail("Missing --vulnerable-version-range"); +} +if (patchedVersionsRaw === undefined) { + fail("Missing --patched-versions"); +} + +const patchedVersions = patchedVersionsRaw === "null" ? null : patchedVersionsRaw; +const description = fs.readFileSync(descriptionPath, "utf8"); + +const payload = { + summary: args.summary, + severity: args.severity, + description, + vulnerabilities: [ + { + package: { + ecosystem, + name: packageName, + }, + vulnerable_version_range: vulnerableRange, + patched_versions: patchedVersions, + vulnerable_functions: [], + }, + ], +}; + +const patchFile = writeTempJson(payload); +runGh([ + "api", + "-H", + "X-GitHub-Api-Version: 2022-11-28", + "-X", + "PATCH", + advisoryPath, + "--input", + patchFile, +]); + +if (restoredCvss) { + runGh([ + "api", + "-H", + "X-GitHub-Api-Version: 2022-11-28", + "-X", + "PATCH", + advisoryPath, + "-f", + `cvss_vector_string=${restoredCvss}`, + ]); +} + +const refreshed = JSON.parse( + runGh(["api", "-H", "X-GitHub-Api-Version: 2022-11-28", advisoryPath]), +); +console.log( + JSON.stringify( + { + html_url: refreshed.html_url, + state: refreshed.state, + severity: refreshed.severity, + summary: refreshed.summary, + vulnerabilities: refreshed.vulnerabilities, + cvss: refreshed.cvss, + updated_at: refreshed.updated_at, + }, + null, + 2, + ), +);
src/gateway/node-invoke-system-run-approval-match.test.ts+17 −69 modified@@ -1,35 +1,11 @@ import { describe, expect, test } from "vitest"; +import { buildSystemRunApprovalBindingV1 } from "../infra/system-run-approval-binding.js"; import { evaluateSystemRunApprovalMatch } from "./node-invoke-system-run-approval-match.js"; -import { - buildSystemRunApprovalBindingV1, - buildSystemRunApprovalEnvBinding, -} from "./system-run-approval-binding.js"; describe("evaluateSystemRunApprovalMatch", () => { - test("matches legacy command text when binding fields match", () => { + test("rejects approvals that do not carry v1 binding", () => { const result = evaluateSystemRunApprovalMatch({ - cmdText: "echo SAFE", argv: ["echo", "SAFE"], - request: { - host: "node", - command: "echo SAFE", - cwd: "/tmp", - agentId: "agent-1", - sessionKey: "session-1", - }, - binding: { - cwd: "/tmp", - agentId: "agent-1", - sessionKey: "session-1", - }, - }); - expect(result).toEqual({ ok: true }); - }); - - test("rejects legacy command mismatch", () => { - const result = evaluateSystemRunApprovalMatch({ - cmdText: "echo PWNED", - argv: ["echo", "PWNED"], request: { host: "node", command: "echo SAFE", @@ -49,7 +25,6 @@ describe("evaluateSystemRunApprovalMatch", () => { test("enforces exact argv binding in v1 object", () => { const result = evaluateSystemRunApprovalMatch({ - cmdText: "echo SAFE", argv: ["echo", "SAFE"], request: { host: "node", @@ -72,7 +47,6 @@ describe("evaluateSystemRunApprovalMatch", () => { test("rejects argv mismatch in v1 object", () => { const result = evaluateSystemRunApprovalMatch({ - cmdText: "echo SAFE", argv: ["echo", "SAFE"], request: { host: "node", @@ -97,14 +71,18 @@ describe("evaluateSystemRunApprovalMatch", () => { expect(result.code).toBe("APPROVAL_REQUEST_MISMATCH"); }); - test("rejects env overrides when approval record lacks env binding", () => { + test("rejects env overrides when v1 binding has no env hash", () => { const result = evaluateSystemRunApprovalMatch({ - cmdText: "git diff", argv: ["git", "diff"], request: { host: "node", command: "git diff", - commandArgv: ["git", "diff"], + systemRunBindingV1: buildSystemRunApprovalBindingV1({ + argv: ["git", "diff"], + cwd: null, + agentId: null, + sessionKey: null, + }).binding, }, binding: { cwd: null, @@ -121,18 +99,18 @@ describe("evaluateSystemRunApprovalMatch", () => { }); test("accepts matching env hash with reordered keys", () => { - const envBinding = buildSystemRunApprovalEnvBinding({ - SAFE_A: "1", - SAFE_B: "2", - }); const result = evaluateSystemRunApprovalMatch({ - cmdText: "git diff", argv: ["git", "diff"], request: { host: "node", command: "git diff", - commandArgv: ["git", "diff"], - envHash: envBinding.envHash, + systemRunBindingV1: buildSystemRunApprovalBindingV1({ + argv: ["git", "diff"], + cwd: null, + agentId: null, + sessionKey: null, + env: { SAFE_A: "1", SAFE_B: "2" }, + }).binding, }, binding: { cwd: null, @@ -146,7 +124,6 @@ describe("evaluateSystemRunApprovalMatch", () => { test("rejects non-node host requests", () => { const result = evaluateSystemRunApprovalMatch({ - cmdText: "echo SAFE", argv: ["echo", "SAFE"], request: { host: "gateway", @@ -165,13 +142,11 @@ describe("evaluateSystemRunApprovalMatch", () => { expect(result.code).toBe("APPROVAL_REQUEST_MISMATCH"); }); - test("prefers v1 binding over legacy command text fields", () => { + test("uses v1 binding even when legacy command text diverges", () => { const result = evaluateSystemRunApprovalMatch({ - cmdText: "echo SAFE", argv: ["echo", "SAFE"], request: { host: "node", - // Intentionally stale legacy fields; v1 should be authoritative. command: "echo STALE", commandArgv: ["echo STALE"], systemRunBindingV1: buildSystemRunApprovalBindingV1({ @@ -189,31 +164,4 @@ describe("evaluateSystemRunApprovalMatch", () => { }); expect(result).toEqual({ ok: true }); }); - - test("rejects v1 mismatch even when legacy command text matches", () => { - const result = evaluateSystemRunApprovalMatch({ - cmdText: "echo SAFE", - argv: ["echo", "SAFE"], - request: { - host: "node", - command: "echo SAFE", - systemRunBindingV1: buildSystemRunApprovalBindingV1({ - argv: ["echo SAFE"], - cwd: null, - agentId: null, - sessionKey: null, - }).binding, - }, - binding: { - cwd: null, - agentId: null, - sessionKey: null, - }, - }); - expect(result.ok).toBe(false); - if (result.ok) { - throw new Error("unreachable"); - } - expect(result.code).toBe("APPROVAL_REQUEST_MISMATCH"); - }); });
src/gateway/node-invoke-system-run-approval-match.ts+10 −15 modified@@ -1,10 +1,10 @@ import type { ExecApprovalRequestPayload } from "../infra/exec-approvals.js"; import { buildSystemRunApprovalBindingV1, - matchLegacySystemRunApprovalBinding, + missingSystemRunApprovalBindingV1, matchSystemRunApprovalBindingV1, type SystemRunApprovalMatchResult, -} from "./system-run-approval-binding.js"; +} from "../infra/system-run-approval-binding.js"; export type SystemRunApprovalBinding = { cwd: string | null; @@ -21,11 +21,10 @@ function requestMismatch(): SystemRunApprovalMatchResult { }; } -export { toSystemRunApprovalMismatchError } from "./system-run-approval-binding.js"; -export type { SystemRunApprovalMatchResult } from "./system-run-approval-binding.js"; +export { toSystemRunApprovalMismatchError } from "../infra/system-run-approval-binding.js"; +export type { SystemRunApprovalMatchResult } from "../infra/system-run-approval-binding.js"; export function evaluateSystemRunApprovalMatch(params: { - cmdText: string; argv: string[]; request: ExecApprovalRequestPayload; binding: SystemRunApprovalBinding; @@ -43,18 +42,14 @@ export function evaluateSystemRunApprovalMatch(params: { }); const expectedBinding = params.request.systemRunBindingV1; - if (expectedBinding) { - return matchSystemRunApprovalBindingV1({ - expected: expectedBinding, - actual: actualBinding.binding, + if (!expectedBinding) { + return missingSystemRunApprovalBindingV1({ actualEnvKeys: actualBinding.envKeys, }); } - - return matchLegacySystemRunApprovalBinding({ - request: params.request, - cmdText: params.cmdText, - argv: params.argv, - binding: params.binding, + return matchSystemRunApprovalBindingV1({ + expected: expectedBinding, + actual: actualBinding.binding, + actualEnvKeys: actualBinding.envKeys, }); }
src/gateway/node-invoke-system-run-approval.test.ts+40 −4 modified@@ -1,7 +1,10 @@ import { describe, expect, test } from "vitest"; +import { + buildSystemRunApprovalBindingV1, + buildSystemRunApprovalEnvBinding, +} from "../infra/system-run-approval-binding.js"; import { ExecApprovalManager, type ExecApprovalRecord } from "./exec-approval-manager.js"; import { sanitizeSystemRunParamsForForwarding } from "./node-invoke-system-run-approval.js"; -import { buildSystemRunApprovalEnvBinding } from "./system-run-approval-binding.js"; describe("sanitizeSystemRunParamsForForwarding", () => { const now = Date.now(); @@ -14,14 +17,25 @@ describe("sanitizeSystemRunParamsForForwarding", () => { }, }; - function makeRecord(command: string, commandArgv?: string[]): ExecApprovalRecord { + function makeRecord( + command: string, + commandArgv?: string[], + bindingArgv?: string[], + ): ExecApprovalRecord { + const effectiveBindingArgv = bindingArgv ?? commandArgv ?? [command]; return { id: "approval-1", request: { host: "node", nodeId: "node-1", command, commandArgv, + systemRunBindingV1: buildSystemRunApprovalBindingV1({ + argv: effectiveBindingArgv, + cwd: null, + agentId: null, + sessionKey: null, + }).binding, cwd: null, agentId: null, sessionKey: null, @@ -97,7 +111,16 @@ describe("sanitizeSystemRunParamsForForwarding", () => { }, nodeId: "node-1", client, - execApprovalManager: manager(makeRecord("echo SAFE&&whoami")), + execApprovalManager: manager( + makeRecord("echo SAFE&&whoami", undefined, [ + "cmd.exe", + "/d", + "/s", + "/c", + "echo", + "SAFE&&whoami", + ]), + ), nowMs: now, }); expectAllowOnceForwardingResult(result); @@ -135,7 +158,13 @@ describe("sanitizeSystemRunParamsForForwarding", () => { nodeId: "node-1", client, execApprovalManager: manager( - makeRecord('/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc "echo SAFE"'), + makeRecord('/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc "echo SAFE"', undefined, [ + "/usr/bin/env", + "BASH_ENV=/tmp/payload.sh", + "bash", + "-lc", + "echo SAFE", + ]), ), nowMs: now, }); @@ -289,6 +318,13 @@ describe("sanitizeSystemRunParamsForForwarding", () => { host: "node", nodeId: "node-1", command: "echo SAFE", + commandArgv: ["echo", "SAFE"], + systemRunBindingV1: buildSystemRunApprovalBindingV1({ + argv: ["echo", "SAFE"], + cwd: null, + agentId: null, + sessionKey: null, + }).binding, cwd: null, agentId: null, sessionKey: null,
src/gateway/node-invoke-system-run-approval.ts+0 −2 modified@@ -110,7 +110,6 @@ export function sanitizeSystemRunParamsForForwarding(opts: { details: cmdTextResolution.details, }; } - const cmdText = cmdTextResolution.cmdText; const approved = p.approved === true; const requestedDecision = normalizeApprovalDecision(p.approvalDecision); @@ -208,7 +207,6 @@ export function sanitizeSystemRunParamsForForwarding(opts: { } const approvalMatch = evaluateSystemRunApprovalMatch({ - cmdText, argv: cmdTextResolution.argv, request: snapshot.request, binding: {
src/gateway/server-methods/exec-approval.ts+18 −10 modified@@ -3,6 +3,7 @@ import { DEFAULT_EXEC_APPROVAL_TIMEOUT_MS, type ExecApprovalDecision, } from "../../infra/exec-approvals.js"; +import { buildSystemRunApprovalBindingV1 } from "../../infra/system-run-approval-binding.js"; import type { ExecApprovalManager } from "../exec-approval-manager.js"; import { ErrorCodes, @@ -11,7 +12,6 @@ import { validateExecApprovalRequestParams, validateExecApprovalResolveParams, } from "../protocol/index.js"; -import { buildSystemRunApprovalBindingV1 } from "../system-run-approval-binding.js"; import type { GatewayRequestHandlers } from "./types.js"; export function createExecApprovalHandlers( @@ -70,8 +70,24 @@ export function createExecApprovalHandlers( const commandArgv = Array.isArray(p.commandArgv) ? p.commandArgv.map((entry) => String(entry)) : undefined; + if (host === "node" && !nodeId) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "nodeId is required for host=node"), + ); + return; + } + if (host === "node" && (!Array.isArray(commandArgv) || commandArgv.length === 0)) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "commandArgv is required for host=node"), + ); + return; + } const systemRunBindingV1 = - host === "node" && Array.isArray(commandArgv) && commandArgv.length > 0 + host === "node" ? buildSystemRunApprovalBindingV1({ argv: commandArgv, cwd: p.cwd, @@ -80,14 +96,6 @@ export function createExecApprovalHandlers( env: p.env, }) : null; - if (host === "node" && !nodeId) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "nodeId is required for host=node"), - ); - return; - } if (explicitId && manager.getSnapshot(explicitId)) { respond( false,
src/gateway/server-methods/server-methods.test.ts+21 −1 modified@@ -6,10 +6,10 @@ import { fileURLToPath } from "node:url"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { emitAgentEvent } from "../../infra/agent-events.js"; import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js"; +import { buildSystemRunApprovalBindingV1 } from "../../infra/system-run-approval-binding.js"; import { resetLogger, setLoggerOverride } from "../../logging.js"; import { ExecApprovalManager } from "../exec-approval-manager.js"; import { validateExecApprovalRequestParams } from "../protocol/index.js"; -import { buildSystemRunApprovalBindingV1 } from "../system-run-approval-binding.js"; import { waitForAgentJob } from "./agent-job.js"; import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js"; import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.js"; @@ -248,6 +248,7 @@ describe("exec approval handlers", () => { const defaultExecApprovalRequestParams = { command: "echo ok", + commandArgv: ["echo", "ok"], cwd: "/tmp", nodeId: "node-1", host: "node", @@ -384,6 +385,25 @@ describe("exec approval handlers", () => { ); }); + it("rejects host=node approval requests without commandArgv", async () => { + const { handlers, respond, context } = createExecApprovalFixture(); + await requestExecApproval({ + handlers, + respond, + context, + params: { + commandArgv: undefined, + }, + }); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: "commandArgv is required for host=node", + }), + ); + }); + it("broadcasts request + resolve", async () => { const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
src/gateway/server.node-invoke-approval-bypass.test.ts+2 −0 modified@@ -75,9 +75,11 @@ async function requestAllowOnceApproval( nodeId: string, ): Promise<string> { const approvalId = crypto.randomUUID(); + const commandArgv = command.split(/\s+/).filter((part) => part.length > 0); const requestP = rpcReq(ws, "exec.approval.request", { id: approvalId, command, + commandArgv, nodeId, cwd: null, host: "node",
src/gateway/system-run-approval-binding.contract.test.ts+1 −10 modified@@ -3,11 +3,8 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, test } from "vitest"; import type { ExecApprovalRequestPayload } from "../infra/exec-approvals.js"; +import { buildSystemRunApprovalBindingV1 } from "../infra/system-run-approval-binding.js"; import { evaluateSystemRunApprovalMatch } from "./node-invoke-system-run-approval-match.js"; -import { - buildSystemRunApprovalBindingV1, - buildSystemRunApprovalEnvBinding, -} from "./system-run-approval-binding.js"; type FixtureCase = { name: string; @@ -25,10 +22,8 @@ type FixtureCase = { sessionKey?: string | null; env?: Record<string, string>; }; - envHashFrom?: Record<string, string>; }; invoke: { - cmdText: string; argv: string[]; binding: { cwd: string | null; @@ -71,17 +66,13 @@ function buildRequestPayload(entry: FixtureCase): ExecApprovalRequestPayload { env: entry.request.bindingV1.env, }).binding; } - if (entry.request.envHashFrom) { - payload.envHash = buildSystemRunApprovalEnvBinding(entry.request.envHashFrom).envHash; - } return payload; } describe("system-run approval binding contract fixtures", () => { for (const entry of fixture.cases) { test(entry.name, () => { const result = evaluateSystemRunApprovalMatch({ - cmdText: entry.invoke.cmdText, argv: entry.invoke.argv, request: buildRequestPayload(entry), binding: entry.invoke.binding,
src/gateway/system-run-approval-binding.test.ts+1 −1 modified@@ -5,7 +5,7 @@ import { matchSystemRunApprovalBindingV1, matchSystemRunApprovalEnvHash, toSystemRunApprovalMismatchError, -} from "./system-run-approval-binding.js"; +} from "../infra/system-run-approval-binding.js"; describe("buildSystemRunApprovalEnvBinding", () => { test("normalizes keys and produces stable hash regardless of input order", () => {
src/infra/exec-approvals.ts+0 −2 modified@@ -23,8 +23,6 @@ export type SystemRunApprovalBindingV1 = { export type ExecApprovalRequestPayload = { command: string; commandArgv?: string[]; - // Legacy env binding field (used for backward compatibility with old approvals). - envHash?: string | null; // Optional UI-safe env key preview for approval prompts. envKeys?: string[]; systemRunBindingV1?: SystemRunApprovalBindingV1 | null;
src/infra/system-run-approval-binding.ts+7 −48 renamed@@ -1,9 +1,6 @@ import crypto from "node:crypto"; -import type { - ExecApprovalRequestPayload, - SystemRunApprovalBindingV1, -} from "../infra/exec-approvals.js"; -import { normalizeEnvVarKey } from "../infra/host-env-security.js"; +import type { SystemRunApprovalBindingV1 } from "./exec-approvals.js"; +import { normalizeEnvVarKey } from "./host-env-security.js"; type NormalizedSystemRunEnvEntry = [key: string, value: string]; @@ -89,14 +86,6 @@ function argvMatches(expectedArgv: string[], actualArgv: string[]): boolean { return true; } -function readExpectedEnvHash(request: Pick<ExecApprovalRequestPayload, "envHash">): string | null { - if (typeof request.envHash !== "string") { - return null; - } - const trimmed = request.envHash.trim(); - return trimmed ? trimmed : null; -} - export type SystemRunApprovalMatchResult = | { ok: true } | { @@ -180,42 +169,12 @@ export function matchSystemRunApprovalBindingV1(params: { }); } -export function matchLegacySystemRunApprovalBinding(params: { - request: Pick< - ExecApprovalRequestPayload, - "command" | "commandArgv" | "cwd" | "agentId" | "sessionKey" | "envHash" - >; - cmdText: string; - argv: string[]; - binding: { - cwd: string | null; - agentId: string | null; - sessionKey: string | null; - env?: unknown; - }; +export function missingSystemRunApprovalBindingV1(params: { + actualEnvKeys: string[]; }): SystemRunApprovalMatchResult { - const requestedArgv = params.request.commandArgv; - if (Array.isArray(requestedArgv)) { - if (!argvMatches(requestedArgv, params.argv)) { - return requestMismatch(); - } - } else if (!params.cmdText || params.request.command !== params.cmdText) { - return requestMismatch(); - } - if ((params.request.cwd ?? null) !== params.binding.cwd) { - return requestMismatch(); - } - if ((params.request.agentId ?? null) !== params.binding.agentId) { - return requestMismatch(); - } - if ((params.request.sessionKey ?? null) !== params.binding.sessionKey) { - return requestMismatch(); - } - const actualEnvBinding = buildSystemRunApprovalEnvBinding(params.binding.env); - return matchSystemRunApprovalEnvHash({ - expectedEnvHash: readExpectedEnvHash(params.request), - actualEnvHash: actualEnvBinding.envHash, - actualEnvKeys: actualEnvBinding.envKeys, + return requestMismatch({ + requiredBindingVersion: 1, + envKeys: params.actualEnvKeys, }); }
src/infra/system-run-approval-mismatch.contract.test.ts+41 −0 added@@ -0,0 +1,41 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; +import { + toSystemRunApprovalMismatchError, + type SystemRunApprovalMatchResult, +} from "./system-run-approval-binding.js"; + +type FixtureCase = { + name: string; + runId: string; + match: Extract<SystemRunApprovalMatchResult, { ok: false }>; + expected: { + ok: false; + message: string; + details: Record<string, unknown>; + }; +}; + +type Fixture = { + cases: FixtureCase[]; +}; + +const fixturePath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../test/fixtures/system-run-approval-mismatch-contract.json", +); +const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8")) as Fixture; + +describe("system-run approval mismatch contract fixtures", () => { + for (const entry of fixture.cases) { + test(entry.name, () => { + const result = toSystemRunApprovalMismatchError({ + runId: entry.runId, + match: entry.match, + }); + expect(result).toEqual(entry.expected); + }); + } +});
test/fixtures/system-run-approval-binding-contract.json+13 −14 modified@@ -14,7 +14,6 @@ } }, "invoke": { - "cmdText": "git diff", "argv": ["git", "diff"], "binding": { "cwd": null, @@ -39,7 +38,6 @@ } }, "invoke": { - "cmdText": "git diff", "argv": ["git", "diff"], "binding": { "cwd": null, @@ -63,7 +61,6 @@ } }, "invoke": { - "cmdText": "git diff", "argv": ["git", "diff"], "binding": { "cwd": null, @@ -75,14 +72,13 @@ "expected": { "ok": false, "code": "APPROVAL_ENV_BINDING_MISSING" } }, { - "name": "legacy rejects argv mismatch", + "name": "missing binding rejects requests even with matching argv", "request": { "host": "node", "command": "echo SAFE", - "commandArgv": ["echo SAFE"] + "commandArgv": ["echo", "SAFE"] }, "invoke": { - "cmdText": "echo SAFE", "argv": ["echo", "SAFE"], "binding": { "cwd": null, @@ -93,21 +89,24 @@ "expected": { "ok": false, "code": "APPROVAL_REQUEST_MISMATCH" } }, { - "name": "legacy accepts matching env hash", + "name": "v1 stays authoritative when legacy command text diverges", "request": { "host": "node", - "command": "git diff", - "commandArgv": ["git", "diff"], - "envHashFrom": { "SAFE_A": "1", "SAFE_B": "2" } + "command": "echo STALE", + "commandArgv": ["echo", "STALE"], + "bindingV1": { + "argv": ["echo", "SAFE"], + "cwd": null, + "agentId": null, + "sessionKey": null + } }, "invoke": { - "cmdText": "git diff", - "argv": ["git", "diff"], + "argv": ["echo", "SAFE"], "binding": { "cwd": null, "agentId": null, - "sessionKey": null, - "env": { "SAFE_B": "2", "SAFE_A": "1" } + "sessionKey": null } }, "expected": { "ok": true }
test/fixtures/system-run-approval-mismatch-contract.json+67 −0 added@@ -0,0 +1,67 @@ +{ + "cases": [ + { + "name": "request mismatch preserves base details", + "runId": "approval-req-1", + "match": { + "ok": false, + "code": "APPROVAL_REQUEST_MISMATCH", + "message": "approval id does not match request" + }, + "expected": { + "ok": false, + "message": "approval id does not match request", + "details": { + "code": "APPROVAL_REQUEST_MISMATCH", + "runId": "approval-req-1" + } + } + }, + { + "name": "missing env binding keeps env key details", + "runId": "approval-env-missing", + "match": { + "ok": false, + "code": "APPROVAL_ENV_BINDING_MISSING", + "message": "approval id missing env binding for requested env overrides", + "details": { + "envKeys": ["GIT_EXTERNAL_DIFF"] + } + }, + "expected": { + "ok": false, + "message": "approval id missing env binding for requested env overrides", + "details": { + "code": "APPROVAL_ENV_BINDING_MISSING", + "runId": "approval-env-missing", + "envKeys": ["GIT_EXTERNAL_DIFF"] + } + } + }, + { + "name": "env mismatch preserves hash diagnostics", + "runId": "approval-env-mismatch", + "match": { + "ok": false, + "code": "APPROVAL_ENV_MISMATCH", + "message": "approval id env binding mismatch", + "details": { + "envKeys": ["SAFE_A"], + "expectedEnvHash": "expected-hash", + "actualEnvHash": "actual-hash" + } + }, + "expected": { + "ok": false, + "message": "approval id env binding mismatch", + "details": { + "code": "APPROVAL_ENV_MISMATCH", + "runId": "approval-env-mismatch", + "envKeys": ["SAFE_A"], + "expectedEnvHash": "expected-hash", + "actualEnvHash": "actual-hash" + } + } + } + ] +}
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
5- github.com/openclaw/openclaw/commit/10481097f8e6dd0346db9be0b5f27570e1bdfcfaghsapatchWEB
- github.com/advisories/GHSA-hjvp-qhm6-wrh2ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-hjvp-qhm6-wrh2ghsathird-party-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-32058ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-approval-context-binding-weakness-in-system-run-via-host-nodeghsathird-party-advisoryWEB
News mentions
0No linked articles in our index yet.