Medium severity6.3NVD Advisory· Published Mar 31, 2026· Updated Apr 2, 2026
CVE-2026-32921
CVE-2026-32921
Description
OpenClaw before 2026.3.8 contains an approval bypass vulnerability in system.run where mutable script operands are not bound across approval and execution phases. Attackers can obtain approval for script execution, modify the approved script file before execution, and execute different content while maintaining the same approved command shape.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.3.8 | 2026.3.8 |
Affected products
1Patches
2cf3a479bd120fix(node-host): bind bun and deno approval scripts
4 files changed · +512 −9
CHANGELOG.md+1 −0 modified@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Browser/SSRF: block private-network intermediate redirect hops in strict browser navigation flows and fail closed when remote tab-open paths cannot inspect redirect chains. Thanks @zpbrent. - MS Teams/authz: keep `groupPolicy: "allowlist"` enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent. +- Security/system.run: bind approved `bun` and `deno run` script operands to on-disk file snapshots so post-approval script rewrites are denied before execution. ## 2026.3.8
src/node-host/invoke-system-run-plan.test.ts+149 −7 modified@@ -24,27 +24,68 @@ type HardeningCase = { checkRawCommandMatchesArgv?: boolean; }; -function createScriptOperandFixture(tmp: string): { +type ScriptOperandFixture = { command: string[]; scriptPath: string; initialBody: string; -} { + expectedArgvIndex: number; +}; + +type RuntimeFixture = { + name: string; + argv: string[]; + scriptName: string; + initialBody: string; + expectedArgvIndex: number; + binName?: string; +}; + +function createScriptOperandFixture(tmp: string, fixture?: RuntimeFixture): ScriptOperandFixture { + if (fixture) { + return { + command: fixture.argv, + scriptPath: path.join(tmp, fixture.scriptName), + initialBody: fixture.initialBody, + expectedArgvIndex: fixture.expectedArgvIndex, + }; + } if (process.platform === "win32") { - const scriptPath = path.join(tmp, "run.js"); return { command: [process.execPath, "./run.js"], - scriptPath, + scriptPath: path.join(tmp, "run.js"), initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 1, }; } - const scriptPath = path.join(tmp, "run.sh"); return { command: ["/bin/sh", "./run.sh"], - scriptPath, + scriptPath: path.join(tmp, "run.sh"), initialBody: "#!/bin/sh\necho SAFE\n", + expectedArgvIndex: 1, }; } +function withFakeRuntimeBin<T>(params: { binName: string; run: () => T }): T { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `openclaw-${params.binName}-bin-`)); + const binDir = path.join(tmp, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + const runtimePath = path.join(binDir, params.binName); + fs.writeFileSync(runtimePath, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + fs.chmodSync(runtimePath, 0o755); + const oldPath = process.env.PATH; + process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`; + try { + return params.run(); + } finally { + if (oldPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = oldPath; + } + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + describe("hardenApprovedExecutionPaths", () => { const cases: HardeningCase[] = [ { @@ -150,6 +191,63 @@ describe("hardenApprovedExecutionPaths", () => { }); } + const mutableOperandCases: RuntimeFixture[] = [ + { + name: "bun direct file", + binName: "bun", + argv: ["bun", "./run.ts"], + scriptName: "run.ts", + initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 1, + }, + { + name: "bun run file", + binName: "bun", + argv: ["bun", "run", "./run.ts"], + scriptName: "run.ts", + initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 2, + }, + { + name: "deno run file with flags", + binName: "deno", + argv: ["deno", "run", "-A", "--allow-read", "--", "./run.ts"], + scriptName: "run.ts", + initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 5, + }, + ]; + + for (const runtimeCase of mutableOperandCases) { + it(`captures mutable ${runtimeCase.name} operands in approval plans`, () => { + withFakeRuntimeBin({ + binName: runtimeCase.binName!, + run: () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-script-plan-")); + const fixture = createScriptOperandFixture(tmp, runtimeCase); + fs.writeFileSync(fixture.scriptPath, fixture.initialBody); + try { + const prepared = buildSystemRunApprovalPlan({ + command: fixture.command, + cwd: tmp, + }); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + throw new Error("unreachable"); + } + expect(prepared.plan.mutableFileOperand).toEqual({ + argvIndex: fixture.expectedArgvIndex, + path: fs.realpathSync(fixture.scriptPath), + sha256: expect.any(String), + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + }); + }); + } + it("captures mutable shell script operands in approval plans", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-script-plan-")); const fixture = createScriptOperandFixture(tmp); @@ -167,12 +265,56 @@ describe("hardenApprovedExecutionPaths", () => { throw new Error("unreachable"); } expect(prepared.plan.mutableFileOperand).toEqual({ - argvIndex: 1, + argvIndex: fixture.expectedArgvIndex, path: fs.realpathSync(fixture.scriptPath), sha256: expect.any(String), }); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); + + it("does not snapshot bun package script names", () => { + withFakeRuntimeBin({ + binName: "bun", + run: () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bun-package-script-")); + try { + const prepared = buildSystemRunApprovalPlan({ + command: ["bun", "run", "dev"], + cwd: tmp, + }); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + throw new Error("unreachable"); + } + expect(prepared.plan.mutableFileOperand).toBeUndefined(); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + }); + }); + + it("does not snapshot deno eval invocations", () => { + withFakeRuntimeBin({ + binName: "deno", + run: () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-deno-eval-")); + try { + const prepared = buildSystemRunApprovalPlan({ + command: ["deno", "eval", "console.log('SAFE')"], + cwd: tmp, + }); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + throw new Error("unreachable"); + } + expect(prepared.plan.mutableFileOperand).toBeUndefined(); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + }); + }); });
src/node-host/invoke-system-run-plan.ts+231 −2 modified@@ -32,6 +32,89 @@ const MUTABLE_ARGV1_INTERPRETER_PATTERNS = [ /^ruby$/, ] as const; +const BUN_SUBCOMMANDS = new Set([ + "add", + "audit", + "completions", + "create", + "exec", + "help", + "init", + "install", + "link", + "outdated", + "patch", + "pm", + "publish", + "remove", + "repl", + "run", + "test", + "unlink", + "update", + "upgrade", + "x", +]); + +const BUN_OPTIONS_WITH_VALUE = new Set([ + "--backend", + "--bunfig", + "--conditions", + "--config", + "--console-depth", + "--cwd", + "--define", + "--elide-lines", + "--env-file", + "--extension-order", + "--filter", + "--hot", + "--inspect", + "--inspect-brk", + "--inspect-wait", + "--install", + "--jsx-factory", + "--jsx-fragment", + "--jsx-import-source", + "--loader", + "--origin", + "--port", + "--preload", + "--smol", + "--tsconfig-override", + "-c", + "-e", + "-p", + "-r", +]); + +const DENO_RUN_OPTIONS_WITH_VALUE = new Set([ + "--cached-only", + "--cert", + "--config", + "--env-file", + "--ext", + "--harmony-import-attributes", + "--import-map", + "--inspect", + "--inspect-brk", + "--inspect-wait", + "--location", + "--log-level", + "--lock", + "--node-modules-dir", + "--no-check", + "--preload", + "--reload", + "--seed", + "--strace-ops", + "--unstable-bare-node-builtins", + "--v8-flags", + "--watch", + "--watch-exclude", + "-L", +]); + function normalizeString(value: unknown): string | null { if (typeof value !== "string") { return null; @@ -94,6 +177,28 @@ function hashFileContentsSync(filePath: string): string { return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); } +function looksLikePathToken(token: string): boolean { + return ( + token.startsWith(".") || + token.startsWith("/") || + token.startsWith("\\") || + token.includes("/") || + token.includes("\\") || + path.extname(token).length > 0 + ); +} + +function resolvesToExistingFileSync(rawOperand: string, cwd: string | undefined): boolean { + if (!rawOperand) { + return false; + } + try { + return fs.statSync(path.resolve(cwd ?? process.cwd(), rawOperand)).isFile(); + } catch { + return false; + } +} + function unwrapArgvForMutableOperand(argv: string[]): { argv: string[]; baseIndex: number } { let current = argv; let baseIndex = 0; @@ -146,7 +251,117 @@ function resolvePosixShellScriptOperandIndex(argv: string[]): number | null { return null; } -function resolveMutableFileOperandIndex(argv: string[]): number | null { +function resolveOptionFilteredFileOperandIndex(params: { + argv: string[]; + startIndex: number; + cwd: string | undefined; + optionsWithValue?: ReadonlySet<string>; +}): number | null { + let afterDoubleDash = false; + for (let i = params.startIndex; i < params.argv.length; i += 1) { + const token = params.argv[i]?.trim() ?? ""; + if (!token) { + continue; + } + if (afterDoubleDash) { + return resolvesToExistingFileSync(token, params.cwd) ? i : null; + } + if (token === "--") { + afterDoubleDash = true; + continue; + } + if (token === "-") { + return null; + } + if (token.startsWith("-")) { + if (!token.includes("=") && params.optionsWithValue?.has(token)) { + i += 1; + } + continue; + } + return resolvesToExistingFileSync(token, params.cwd) ? i : null; + } + return null; +} + +function resolveOptionFilteredPositionalIndex(params: { + argv: string[]; + startIndex: number; + optionsWithValue?: ReadonlySet<string>; +}): number | null { + let afterDoubleDash = false; + for (let i = params.startIndex; i < params.argv.length; i += 1) { + const token = params.argv[i]?.trim() ?? ""; + if (!token) { + continue; + } + if (afterDoubleDash) { + return i; + } + if (token === "--") { + afterDoubleDash = true; + continue; + } + if (token === "-") { + return null; + } + if (token.startsWith("-")) { + if (!token.includes("=") && params.optionsWithValue?.has(token)) { + i += 1; + } + continue; + } + return i; + } + return null; +} + +function resolveBunScriptOperandIndex(params: { + argv: string[]; + cwd: string | undefined; +}): number | null { + const directIndex = resolveOptionFilteredPositionalIndex({ + argv: params.argv, + startIndex: 1, + optionsWithValue: BUN_OPTIONS_WITH_VALUE, + }); + if (directIndex === null) { + return null; + } + const directToken = params.argv[directIndex]?.trim() ?? ""; + if (directToken === "run") { + return resolveOptionFilteredFileOperandIndex({ + argv: params.argv, + startIndex: directIndex + 1, + cwd: params.cwd, + optionsWithValue: BUN_OPTIONS_WITH_VALUE, + }); + } + if (BUN_SUBCOMMANDS.has(directToken)) { + return null; + } + if (!looksLikePathToken(directToken)) { + return null; + } + return directIndex; +} + +function resolveDenoRunScriptOperandIndex(params: { + argv: string[]; + cwd: string | undefined; +}): number | null { + if ((params.argv[1]?.trim() ?? "") !== "run") { + return null; + } + return resolveOptionFilteredFileOperandIndex({ + argv: params.argv, + startIndex: 2, + cwd: params.cwd, + optionsWithValue: DENO_RUN_OPTIONS_WITH_VALUE, + }); +} + +function resolveMutableFileOperandIndex(argv: string[], cwd: string | undefined): number | null { const unwrapped = unwrapArgvForMutableOperand(argv); const executable = normalizeExecutableToken(unwrapped.argv[0] ?? ""); if (!executable) { @@ -157,6 +372,20 @@ function resolveMutableFileOperandIndex(argv: string[]): number | null { return shellIndex === null ? null : unwrapped.baseIndex + shellIndex; } if (!MUTABLE_ARGV1_INTERPRETER_PATTERNS.some((pattern) => pattern.test(executable))) { + if (executable === "bun") { + const bunIndex = resolveBunScriptOperandIndex({ + argv: unwrapped.argv, + cwd, + }); + return bunIndex === null ? null : unwrapped.baseIndex + bunIndex; + } + if (executable === "deno") { + const denoIndex = resolveDenoRunScriptOperandIndex({ + argv: unwrapped.argv, + cwd, + }); + return denoIndex === null ? null : unwrapped.baseIndex + denoIndex; + } return null; } const operand = unwrapped.argv[1]?.trim() ?? ""; @@ -170,7 +399,7 @@ function resolveMutableFileOperandSnapshotSync(params: { argv: string[]; cwd: string | undefined; }): { ok: true; snapshot: SystemRunApprovalFileOperand | null } | { ok: false; message: string } { - const argvIndex = resolveMutableFileOperandIndex(params.argv); + const argvIndex = resolveMutableFileOperandIndex(params.argv, params.cwd); if (argvIndex === null) { return { ok: true, snapshot: null }; }
src/node-host/invoke-system-run.test.ts+131 −0 modified@@ -109,6 +109,29 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { }; } + function createRuntimeScriptOperandFixture(params: { tmp: string; runtime: "bun" | "deno" }): { + command: string[]; + scriptPath: string; + initialBody: string; + changedBody: string; + } { + const scriptPath = path.join(params.tmp, "run.ts"); + if (params.runtime === "bun") { + return { + command: ["bun", "run", "./run.ts"], + scriptPath, + initialBody: 'console.log("SAFE");\n', + changedBody: 'console.log("PWNED");\n', + }; + } + return { + command: ["deno", "run", "-A", "--allow-read", "--", "./run.ts"], + scriptPath, + initialBody: 'console.log("SAFE");\n', + changedBody: 'console.log("PWNED");\n', + }; + } + function buildNestedEnvShellCommand(params: { depth: number; payload: string }): string[] { return [...Array(params.depth).fill("/usr/bin/env"), "/bin/sh", "-c", params.payload]; } @@ -199,6 +222,30 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { } } + async function withFakeRuntimeOnPath<T>(params: { + runtime: "bun" | "deno"; + run: () => Promise<T>; + }): Promise<T> { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `openclaw-${params.runtime}-path-`)); + const binDir = path.join(tmp, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + const runtimePath = path.join(binDir, params.runtime); + fs.writeFileSync(runtimePath, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + fs.chmodSync(runtimePath, 0o755); + const oldPath = process.env.PATH; + process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`; + try { + return await params.run(); + } finally { + if (oldPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = oldPath; + } + fs.rmSync(tmp, { recursive: true, force: true }); + } + } + function expectCommandPinnedToCanonicalPath(params: { runCommand: MockedRunCommand; expected: string; @@ -788,6 +835,90 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { } }); + for (const runtime of ["bun", "deno"] as const) { + it(`denies approval-based execution when a ${runtime} script operand changes after approval`, async () => { + await withFakeRuntimeOnPath({ + runtime, + run: async () => { + const tmp = fs.mkdtempSync( + path.join(os.tmpdir(), `openclaw-approval-${runtime}-script-drift-`), + ); + const fixture = createRuntimeScriptOperandFixture({ tmp, runtime }); + fs.writeFileSync(fixture.scriptPath, fixture.initialBody); + try { + const prepared = buildSystemRunApprovalPlan({ + command: fixture.command, + cwd: tmp, + }); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + throw new Error("unreachable"); + } + + fs.writeFileSync(fixture.scriptPath, fixture.changedBody); + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + command: prepared.plan.argv, + rawCommand: prepared.plan.rawCommand, + systemRunPlan: prepared.plan, + cwd: prepared.plan.cwd ?? tmp, + approved: true, + security: "full", + ask: "off", + }); + + expect(runCommand).not.toHaveBeenCalled(); + expectInvokeErrorMessage(sendInvokeResult, { + message: "SYSTEM_RUN_DENIED: approval script operand changed before execution", + exact: true, + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + }); + }); + + it(`keeps approved ${runtime} script execution working when the script is unchanged`, async () => { + await withFakeRuntimeOnPath({ + runtime, + run: async () => { + const tmp = fs.mkdtempSync( + path.join(os.tmpdir(), `openclaw-approval-${runtime}-script-stable-`), + ); + const fixture = createRuntimeScriptOperandFixture({ tmp, runtime }); + fs.writeFileSync(fixture.scriptPath, fixture.initialBody); + try { + const prepared = buildSystemRunApprovalPlan({ + command: fixture.command, + cwd: tmp, + }); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + throw new Error("unreachable"); + } + + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + command: prepared.plan.argv, + rawCommand: prepared.plan.rawCommand, + systemRunPlan: prepared.plan, + cwd: prepared.plan.cwd ?? tmp, + approved: true, + security: "full", + ask: "off", + }); + + expect(runCommand).toHaveBeenCalledTimes(1); + expectInvokeOk(sendInvokeResult); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + }); + }); + } + it("denies ./sh wrapper spoof in allowlist on-miss mode before execution", async () => { const marker = path.join(os.tmpdir(), `openclaw-wrapper-spoof-${process.pid}-${Date.now()}`); const runCommand = vi.fn(async () => {
c76d29208bf6fix(node-host): bind approved script operands
12 files changed · +374 −4
CHANGELOG.md+1 −0 modified@@ -299,6 +299,7 @@ Docs: https://docs.openclaw.ai - Agents/OpenAI-responses compatibility: strip unsupported `store` payload fields when `supportsStore=false` (including OpenAI-compatible non-OpenAI providers) while preserving server-compaction payload behavior. (#39219) Thanks @ademczuk. - Agents/model fallback visibility: warn when configured model IDs cannot be resolved and fallback is applied, with log-safe sanitization of model text to prevent control-sequence injection in warning output. (#39215) Thanks @ademczuk. - Outbound delivery replay safety: use two-phase delivery ACK markers (`.json` -> `.delivered` -> unlink) and startup marker cleanup so crash windows between send and cleanup do not replay already-delivered messages. (#38668) Thanks @Gundam98. +- Nodes/system.run approval binding: carry prepared approval plans through gateway forwarding and bind interpreter-style script operands across approval to execution, so post-approval script rewrites are denied while unchanged approved script runs keep working. Thanks @tdjackey for reporting. ## 2026.3.2
docs/tools/exec-approvals.md+3 −0 modified@@ -30,6 +30,9 @@ Trust model note: - Gateway-authenticated callers are trusted operators for that Gateway. - Paired nodes extend that trusted operator capability onto the node host. - Exec approvals reduce accidental execution risk, but are not a per-user auth boundary. +- Approved node-host runs also bind canonical execution context: canonical cwd, pinned executable + path when applicable, and interpreter-style script operands. If a bound script changes after + approval but before execution, the run is denied instead of executing drifted content. macOS split:
src/gateway/node-invoke-system-run-approval.test.ts+1 −0 modified@@ -278,6 +278,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { const forwarded = result.params as Record<string, unknown>; expect(forwarded.command).toEqual(["/usr/bin/echo", "SAFE"]); expect(forwarded.rawCommand).toBe("/usr/bin/echo SAFE"); + expect(forwarded.systemRunPlan).toEqual(record.request.systemRunPlan); expect(forwarded.cwd).toBe("/real/cwd"); expect(forwarded.agentId).toBe("main"); expect(forwarded.sessionKey).toBe("agent:main:main");
src/gateway/node-invoke-system-run-approval.ts+3 −0 modified@@ -13,6 +13,7 @@ import { type SystemRunParamsLike = { command?: unknown; rawCommand?: unknown; + systemRunPlan?: unknown; cwd?: unknown; env?: unknown; timeoutMs?: unknown; @@ -69,6 +70,7 @@ function pickSystemRunParams(raw: Record<string, unknown>): Record<string, unkno for (const key of [ "command", "rawCommand", + "systemRunPlan", "cwd", "env", "timeoutMs", @@ -225,6 +227,7 @@ export function sanitizeSystemRunParamsForForwarding(opts: { } if (runtimeContext.plan) { next.command = [...runtimeContext.plan.argv]; + next.systemRunPlan = runtimeContext.plan; if (runtimeContext.rawCommand) { next.rawCommand = runtimeContext.rawCommand; } else {
src/gateway/protocol/schema/exec-approvals.ts+13 −0 modified@@ -98,6 +98,19 @@ export const ExecApprovalRequestParamsSchema = Type.Object( rawCommand: Type.Union([Type.String(), Type.Null()]), agentId: Type.Union([Type.String(), Type.Null()]), sessionKey: Type.Union([Type.String(), Type.Null()]), + mutableFileOperand: Type.Optional( + Type.Union([ + Type.Object( + { + argvIndex: Type.Integer({ minimum: 0 }), + path: Type.String(), + sha256: Type.String(), + }, + { additionalProperties: false }, + ), + Type.Null(), + ]), + ), }, { additionalProperties: false }, ),
src/infra/exec-approvals.ts+7 −0 modified@@ -43,12 +43,19 @@ export type SystemRunApprovalBinding = { envHash: string | null; }; +export type SystemRunApprovalFileOperand = { + argvIndex: number; + path: string; + sha256: string; +}; + export type SystemRunApprovalPlan = { argv: string[]; cwd: string | null; rawCommand: string | null; agentId: string | null; sessionKey: string | null; + mutableFileOperand?: SystemRunApprovalFileOperand | null; }; export type ExecApprovalRequestPayload = {
src/infra/system-run-approval-binding.ts+38 −1 modified@@ -1,10 +1,42 @@ import crypto from "node:crypto"; -import type { SystemRunApprovalBinding, SystemRunApprovalPlan } from "./exec-approvals.js"; +import type { + SystemRunApprovalBinding, + SystemRunApprovalFileOperand, + SystemRunApprovalPlan, +} from "./exec-approvals.js"; import { normalizeEnvVarKey } from "./host-env-security.js"; import { normalizeNonEmptyString, normalizeStringArray } from "./system-run-normalize.js"; type NormalizedSystemRunEnvEntry = [key: string, value: string]; +function normalizeSystemRunApprovalFileOperand( + value: unknown, +): SystemRunApprovalFileOperand | null | undefined { + if (value === undefined) { + return undefined; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const candidate = value as Record<string, unknown>; + const argvIndex = + typeof candidate.argvIndex === "number" && + Number.isInteger(candidate.argvIndex) && + candidate.argvIndex >= 0 + ? candidate.argvIndex + : null; + const filePath = normalizeNonEmptyString(candidate.path); + const sha256 = normalizeNonEmptyString(candidate.sha256); + if (argvIndex === null || !filePath || !sha256) { + return null; + } + return { + argvIndex, + path: filePath, + sha256, + }; +} + export function normalizeSystemRunApprovalPlan(value: unknown): SystemRunApprovalPlan | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; @@ -14,12 +46,17 @@ export function normalizeSystemRunApprovalPlan(value: unknown): SystemRunApprova if (argv.length === 0) { return null; } + const mutableFileOperand = normalizeSystemRunApprovalFileOperand(candidate.mutableFileOperand); + if (candidate.mutableFileOperand !== undefined && mutableFileOperand === null) { + return null; + } return { argv, cwd: normalizeNonEmptyString(candidate.cwd), rawCommand: normalizeNonEmptyString(candidate.rawCommand), agentId: normalizeNonEmptyString(candidate.agentId), sessionKey: normalizeNonEmptyString(candidate.sessionKey), + mutableFileOperand: mutableFileOperand ?? undefined, }; }
src/node-host/invoke-system-run-plan.test.ts+24 −0 modified@@ -128,4 +128,28 @@ describe("hardenApprovedExecutionPaths", () => { } }); } + + it("captures mutable shell script operands in approval plans", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-script-plan-")); + const script = path.join(tmp, "run.sh"); + fs.writeFileSync(script, "#!/bin/sh\necho SAFE\n"); + fs.chmodSync(script, 0o755); + try { + const prepared = buildSystemRunApprovalPlan({ + command: ["/bin/sh", "./run.sh"], + cwd: tmp, + }); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + throw new Error("unreachable"); + } + expect(prepared.plan.mutableFileOperand).toEqual({ + argvIndex: 1, + path: fs.realpathSync(script), + sha256: expect.any(String), + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); });
src/node-host/invoke-system-run-plan.ts+177 −2 modified@@ -1,15 +1,37 @@ +import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import type { SystemRunApprovalPlan } from "../infra/exec-approvals.js"; +import type { + SystemRunApprovalFileOperand, + SystemRunApprovalPlan, +} from "../infra/exec-approvals.js"; import { resolveCommandResolutionFromArgv } from "../infra/exec-command-resolution.js"; +import { + POSIX_SHELL_WRAPPERS, + normalizeExecutableToken, + unwrapKnownDispatchWrapperInvocation, + unwrapKnownShellMultiplexerInvocation, +} from "../infra/exec-wrapper-resolution.js"; import { sameFileIdentity } from "../infra/file-identity.js"; +import { + POSIX_INLINE_COMMAND_FLAGS, + resolveInlineCommandMatch, +} from "../infra/shell-inline-command.js"; import { formatExecCommand, resolveSystemRunCommand } from "../infra/system-run-command.js"; export type ApprovedCwdSnapshot = { cwd: string; stat: fs.Stats; }; +const MUTABLE_ARGV1_INTERPRETER_PATTERNS = [ + /^(?:node|nodejs)$/, + /^perl$/, + /^php$/, + /^python(?:\d+(?:\.\d+)*)?$/, + /^ruby$/, +] as const; + function normalizeString(value: unknown): string | null { if (typeof value !== "string") { return null; @@ -68,6 +90,125 @@ function shouldPinExecutableForApproval(params: { return (params.wrapperChain?.length ?? 0) === 0; } +function hashFileContentsSync(filePath: string): string { + return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +function unwrapArgvForMutableOperand(argv: string[]): { argv: string[]; baseIndex: number } { + let current = argv; + let baseIndex = 0; + while (true) { + const dispatchUnwrap = unwrapKnownDispatchWrapperInvocation(current); + if (dispatchUnwrap.kind === "unwrapped") { + baseIndex += current.length - dispatchUnwrap.argv.length; + current = dispatchUnwrap.argv; + continue; + } + const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(current); + if (shellMultiplexerUnwrap.kind === "unwrapped") { + baseIndex += current.length - shellMultiplexerUnwrap.argv.length; + current = shellMultiplexerUnwrap.argv; + continue; + } + return { argv: current, baseIndex }; + } +} + +function resolvePosixShellScriptOperandIndex(argv: string[]): number | null { + if ( + resolveInlineCommandMatch(argv, POSIX_INLINE_COMMAND_FLAGS, { + allowCombinedC: true, + }).valueTokenIndex !== null + ) { + return null; + } + let afterDoubleDash = false; + for (let i = 1; i < argv.length; i += 1) { + const token = argv[i]?.trim() ?? ""; + if (!token) { + continue; + } + if (token === "-") { + return null; + } + if (!afterDoubleDash && token === "--") { + afterDoubleDash = true; + continue; + } + if (!afterDoubleDash && token === "-s") { + return null; + } + if (!afterDoubleDash && token.startsWith("-")) { + continue; + } + return i; + } + return null; +} + +function resolveMutableFileOperandIndex(argv: string[]): number | null { + const unwrapped = unwrapArgvForMutableOperand(argv); + const executable = normalizeExecutableToken(unwrapped.argv[0] ?? ""); + if (!executable) { + return null; + } + if ((POSIX_SHELL_WRAPPERS as ReadonlySet<string>).has(executable)) { + const shellIndex = resolvePosixShellScriptOperandIndex(unwrapped.argv); + return shellIndex === null ? null : unwrapped.baseIndex + shellIndex; + } + if (!MUTABLE_ARGV1_INTERPRETER_PATTERNS.some((pattern) => pattern.test(executable))) { + return null; + } + const operand = unwrapped.argv[1]?.trim() ?? ""; + if (!operand || operand === "-" || operand.startsWith("-")) { + return null; + } + return unwrapped.baseIndex + 1; +} + +function resolveMutableFileOperandSnapshotSync(params: { + argv: string[]; + cwd: string | undefined; +}): { ok: true; snapshot: SystemRunApprovalFileOperand | null } | { ok: false; message: string } { + const argvIndex = resolveMutableFileOperandIndex(params.argv); + if (argvIndex === null) { + return { ok: true, snapshot: null }; + } + const rawOperand = params.argv[argvIndex]?.trim(); + if (!rawOperand) { + return { + ok: false, + message: "SYSTEM_RUN_DENIED: approval requires a stable script operand", + }; + } + const resolvedPath = path.resolve(params.cwd ?? process.cwd(), rawOperand); + let realPath: string; + let stat: fs.Stats; + try { + realPath = fs.realpathSync(resolvedPath); + stat = fs.statSync(realPath); + } catch { + return { + ok: false, + message: "SYSTEM_RUN_DENIED: approval requires an existing script operand", + }; + } + if (!stat.isFile()) { + return { + ok: false, + message: "SYSTEM_RUN_DENIED: approval requires a file script operand", + }; + } + return { + ok: true, + snapshot: { + argvIndex, + path: realPath, + sha256: hashFileContentsSync(realPath), + }, + }; +} + function resolveCanonicalApprovalCwdSync(cwd: string): | { ok: true; @@ -135,6 +276,32 @@ export function revalidateApprovedCwdSnapshot(params: { snapshot: ApprovedCwdSna return sameFileIdentity(params.snapshot.stat, current.snapshot.stat); } +export function revalidateApprovedMutableFileOperand(params: { + snapshot: SystemRunApprovalFileOperand; + argv: string[]; + cwd: string | undefined; +}): boolean { + const operand = params.argv[params.snapshot.argvIndex]?.trim(); + if (!operand) { + return false; + } + const resolvedPath = path.resolve(params.cwd ?? process.cwd(), operand); + let realPath: string; + try { + realPath = fs.realpathSync(resolvedPath); + } catch { + return false; + } + if (realPath !== params.snapshot.path) { + return false; + } + try { + return hashFileContentsSync(realPath) === params.snapshot.sha256; + } catch { + return false; + } +} + export function hardenApprovedExecutionPaths(params: { approvedByAsk: boolean; argv: string[]; @@ -257,6 +424,13 @@ export function buildSystemRunApprovalPlan(params: { const rawCommand = hardening.argvChanged ? formatExecCommand(hardening.argv) || null : command.cmdText.trim() || null; + const mutableFileOperand = resolveMutableFileOperandSnapshotSync({ + argv: hardening.argv, + cwd: hardening.cwd, + }); + if (!mutableFileOperand.ok) { + return { ok: false, message: mutableFileOperand.message }; + } return { ok: true, plan: { @@ -265,7 +439,8 @@ export function buildSystemRunApprovalPlan(params: { rawCommand, agentId: normalizeString(params.agentId), sessionKey: normalizeString(params.sessionKey), + mutableFileOperand: mutableFileOperand.snapshot ?? undefined, }, - cmdText: command.cmdText, + cmdText: rawCommand ?? formatExecCommand(hardening.argv), }; }
src/node-host/invoke-system-run.test.ts+73 −0 modified@@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, type Mock, vi } from "vitest"; +import type { SystemRunApprovalPlan } from "../infra/exec-approvals.js"; import { saveExecApprovals } from "../infra/exec-approvals.js"; import type { ExecHostResponse } from "../infra/exec-host.js"; import { buildSystemRunApprovalPlan } from "./invoke-system-run-plan.js"; @@ -235,6 +236,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { runViaResponse?: ExecHostResponse | null; command?: string[]; rawCommand?: string | null; + systemRunPlan?: SystemRunApprovalPlan | null; cwd?: string; security?: "full" | "allowlist"; ask?: "off" | "on-miss" | "always"; @@ -289,6 +291,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { params: { command: params.command ?? ["echo", "ok"], rawCommand: params.rawCommand, + systemRunPlan: params.systemRunPlan, cwd: params.cwd, approved: params.approved ?? false, sessionKey: "agent:main:main", @@ -687,6 +690,76 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { } }); + it("denies approval-based execution when a script operand changes after approval", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-script-drift-")); + const script = path.join(tmp, "run.sh"); + fs.writeFileSync(script, "#!/bin/sh\necho SAFE\n"); + fs.chmodSync(script, 0o755); + try { + const prepared = buildSystemRunApprovalPlan({ + command: ["/bin/sh", "./run.sh"], + cwd: tmp, + }); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + throw new Error("unreachable"); + } + + fs.writeFileSync(script, "#!/bin/sh\necho PWNED\n"); + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + command: prepared.plan.argv, + rawCommand: prepared.plan.rawCommand, + systemRunPlan: prepared.plan, + cwd: prepared.plan.cwd ?? tmp, + approved: true, + security: "full", + ask: "off", + }); + + expect(runCommand).not.toHaveBeenCalled(); + expectInvokeErrorMessage(sendInvokeResult, { + message: "SYSTEM_RUN_DENIED: approval script operand changed before execution", + exact: true, + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("keeps approved shell script execution working when the script is unchanged", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-script-stable-")); + const script = path.join(tmp, "run.sh"); + fs.writeFileSync(script, "#!/bin/sh\necho SAFE\n"); + fs.chmodSync(script, 0o755); + try { + const prepared = buildSystemRunApprovalPlan({ + command: ["/bin/sh", "./run.sh"], + cwd: tmp, + }); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + throw new Error("unreachable"); + } + + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + command: prepared.plan.argv, + rawCommand: prepared.plan.rawCommand, + systemRunPlan: prepared.plan, + cwd: prepared.plan.cwd ?? tmp, + approved: true, + security: "full", + ask: "off", + }); + + expect(runCommand).toHaveBeenCalledTimes(1); + expectInvokeOk(sendInvokeResult); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("denies ./sh wrapper spoof in allowlist on-miss mode before execution", async () => { const marker = path.join(os.tmpdir(), `openclaw-wrapper-spoof-${process.pid}-${Date.now()}`); const runCommand = vi.fn(async () => {
src/node-host/invoke-system-run.ts+32 −0 modified@@ -15,6 +15,7 @@ import { 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 { normalizeSystemRunApprovalPlan } from "../infra/system-run-approval-binding.js"; import { resolveSystemRunCommand } from "../infra/system-run-command.js"; import { logWarn } from "../logger.js"; import { evaluateSystemRunPolicy, resolveExecApprovalDecision } from "./exec-policy.js"; @@ -27,6 +28,7 @@ import { import { hardenApprovedExecutionPaths, revalidateApprovedCwdSnapshot, + revalidateApprovedMutableFileOperand, type ApprovedCwdSnapshot, } from "./invoke-system-run-plan.js"; import type { @@ -63,6 +65,7 @@ type SystemRunParsePhase = { argv: string[]; shellCommand: string | null; cmdText: string; + approvalPlan: import("../infra/exec-approvals.js").SystemRunApprovalPlan | null; agentId: string | undefined; sessionKey: string; runId: string; @@ -92,6 +95,8 @@ type SystemRunPolicyPhase = SystemRunParsePhase & { const safeBinTrustedDirWarningCache = new Set<string>(); const APPROVAL_CWD_DRIFT_DENIED_MESSAGE = "SYSTEM_RUN_DENIED: approval cwd changed before execution"; +const APPROVAL_SCRIPT_OPERAND_DRIFT_DENIED_MESSAGE = + "SYSTEM_RUN_DENIED: approval script operand changed before execution"; function warnWritableTrustedDirOnce(message: string): void { if (safeBinTrustedDirWarningCache.has(message)) { @@ -197,6 +202,17 @@ async function parseSystemRunPhase( const shellCommand = command.shellCommand; const cmdText = command.cmdText; + const approvalPlan = + opts.params.systemRunPlan === undefined + ? null + : normalizeSystemRunApprovalPlan(opts.params.systemRunPlan); + if (opts.params.systemRunPlan !== undefined && !approvalPlan) { + await opts.sendInvokeResult({ + ok: false, + error: { code: "INVALID_REQUEST", message: "systemRunPlan invalid" }, + }); + return null; + } const agentId = opts.params.agentId?.trim() || undefined; const sessionKey = opts.params.sessionKey?.trim() || "node"; const runId = opts.params.runId?.trim() || crypto.randomUUID(); @@ -208,6 +224,7 @@ async function parseSystemRunPhase( argv: command.argv, shellCommand, cmdText, + approvalPlan, agentId, sessionKey, runId, @@ -361,6 +378,21 @@ async function executeSystemRunPhase( }); return; } + if ( + phase.approvalPlan?.mutableFileOperand && + !revalidateApprovedMutableFileOperand({ + snapshot: phase.approvalPlan.mutableFileOperand, + argv: phase.argv, + cwd: phase.cwd, + }) + ) { + logWarn(`security: system.run approval script drift blocked (runId=${phase.runId})`); + await sendSystemRunDenied(opts, phase.execution, { + reason: "approval-required", + message: APPROVAL_SCRIPT_OPERAND_DRIFT_DENIED_MESSAGE, + }); + return; + } const useMacAppExec = opts.preferMacAppExecHost; if (useMacAppExec) {
src/node-host/invoke-types.ts+2 −1 modified@@ -1,8 +1,9 @@ -import type { SkillBinTrustEntry } from "../infra/exec-approvals.js"; +import type { SkillBinTrustEntry, SystemRunApprovalPlan } from "../infra/exec-approvals.js"; export type SystemRunParams = { command: string[]; rawCommand?: string | null; + systemRunPlan?: SystemRunApprovalPlan | null; cwd?: string | null; env?: Record<string, string>; timeoutMs?: number | 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- github.com/openclaw/openclaw/commit/c76d29208bf6a7f058d2cf582519d28069e42240nvdPatchWEB
- github.com/openclaw/openclaw/commit/cf3a479bd1204f62eef7dd82b4aa328749ae6c91nvdPatchWEB
- github.com/advisories/GHSA-8g75-q649-6pv6ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-8g75-q649-6pv6nvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-32921ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-script-content-modification-via-mutable-operand-binding-in-system-runnvdThird Party AdvisoryWEB
News mentions
0No linked articles in our index yet.