From b7a37c202316a58c30a28ef134d4c0e3c217a28f Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Thu, 12 Mar 2026 01:34:12 +0100 Subject: [PATCH] fix(node-host): extend script-runner set and add fail-closed guard for mutable-file approval tsx, jiti, ts-node, ts-node-esm, vite-node, and esno were not recognized as interpreter-style script runners in invoke-system-run-plan.ts. These runners produced mutableFileOperand: null, causing invoke-system-run.ts to skip revalidation entirely. A mutated script payload would execute without the approval binding check that node ./run.js already enforced. Two-part fix: - Add tsx, jiti, and related TypeScript/ESM loaders to the known script runner set so they produce a valid mutableFileOperand from the planner - Add a fail-closed runtime guard in invoke-system-run.ts that denies execution when a script run should have a mutable-file binding but the approval plan is missing it, preventing unknown future runners from silently bypassing revalidation Fixes GHSA-qc36-x95h-7j53 --- pnpm-lock.yaml | 94 +++++++++++++++++-- src/node-host/invoke-system-run-plan.test.ts | 54 +++++++++++ src/node-host/invoke-system-run-plan.ts | 19 +++- src/node-host/invoke-system-run.test.ts | 99 ++++++++++++++++---- src/node-host/invoke-system-run.ts | 26 +++++ 5 files changed, 267 insertions(+), 25 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a994e355b52..1e26495971c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -341,10 +341,9 @@ importers: google-auth-library: specifier: ^10.6.1 version: 10.6.1 - devDependencies: openclaw: - specifier: workspace:* - version: link:../.. + specifier: '>=2026.3.7' + version: 2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.7)(node-llama-cpp@3.16.2(typescript@5.9.3)) extensions/imessage: {} @@ -402,10 +401,10 @@ importers: version: 4.3.6 extensions/memory-core: - devDependencies: + dependencies: openclaw: - specifier: workspace:* - version: link:../.. + specifier: '>=2026.3.7' + version: 2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.7)(node-llama-cpp@3.16.2(typescript@5.9.3)) extensions/memory-lancedb: dependencies: @@ -5526,6 +5525,14 @@ packages: zod: optional: true + openclaw@2026.3.8: + resolution: {integrity: sha512-e5Rk2Aj55sD/5LyX94mdYCQj7zpHXo0xIZsl+k140+nRopePfPAxC7nsu0V/NyypPRtaotP1riFfzK7IhaYkuQ==} + engines: {node: '>=22.12.0'} + hasBin: true + peerDependencies: + '@napi-rs/canvas': ^0.1.89 + node-llama-cpp: 3.16.2 + opus-decoder@0.7.11: resolution: {integrity: sha512-+e+Jz3vGQLxRTBHs8YJQPRPc1Tr+/aC6coV/DlZylriA29BdHQAYXhvNRKtjftof17OFng0+P4wsFIqQu3a48A==} @@ -12831,6 +12838,81 @@ snapshots: ws: 8.19.0 zod: 4.3.6 + openclaw@2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.7)(node-llama-cpp@3.16.2(typescript@5.9.3)): + dependencies: + '@agentclientprotocol/sdk': 0.15.0(zod@4.3.6) + '@aws-sdk/client-bedrock': 3.1007.0 + '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.7)(opusscript@0.1.1) + '@clack/prompts': 1.1.0 + '@discordjs/voice': 0.19.1(@discordjs/opus@0.10.0)(opusscript@0.1.1) + '@grammyjs/runner': 2.0.3(grammy@1.41.1) + '@grammyjs/transformer-throttler': 1.2.1(grammy@1.41.1) + '@homebridge/ciao': 1.3.5 + '@larksuiteoapi/node-sdk': 1.59.0 + '@line/bot-sdk': 10.6.0 + '@lydell/node-pty': 1.2.0-beta.3 + '@mariozechner/pi-agent-core': 0.57.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-coding-agent': 0.57.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.57.1 + '@mozilla/readability': 0.6.0 + '@napi-rs/canvas': 0.1.95 + '@sinclair/typebox': 0.34.48 + '@slack/bolt': 4.6.0(@types/express@5.0.6) + '@slack/web-api': 7.14.1 + '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) + ajv: 8.18.0 + chalk: 5.6.2 + chokidar: 5.0.0 + cli-highlight: 2.1.11 + commander: 14.0.3 + croner: 10.0.1 + discord-api-types: 0.38.42 + dotenv: 17.3.1 + express: 5.2.1 + file-type: 21.3.1 + grammy: 1.41.1 + https-proxy-agent: 7.0.6 + ipaddr.js: 2.3.0 + jiti: 2.6.1 + json5: 2.2.3 + jszip: 3.10.1 + linkedom: 0.18.12 + long: 5.3.2 + markdown-it: 14.1.1 + node-edge-tts: 1.2.10 + node-llama-cpp: 3.16.2(typescript@5.9.3) + opusscript: 0.1.1 + osc-progress: 0.3.0 + pdfjs-dist: 5.5.207 + playwright-core: 1.58.2 + qrcode-terminal: 0.12.0 + sharp: 0.34.5 + sqlite-vec: 0.1.7-alpha.2 + tar: 7.5.11 + tslog: 4.10.2 + undici: 7.22.0 + ws: 8.19.0 + yaml: 2.8.2 + zod: 4.3.6 + transitivePeerDependencies: + - '@discordjs/opus' + - '@modelcontextprotocol/sdk' + - '@types/express' + - audio-decode + - aws-crt + - bufferutil + - canvas + - debug + - encoding + - ffmpeg-static + - hono + - jimp + - link-preview-js + - node-opus + - supports-color + - utf-8-validate + opus-decoder@0.7.11: dependencies: '@wasm-audio-decoders/common': 9.0.7 diff --git a/src/node-host/invoke-system-run-plan.test.ts b/src/node-host/invoke-system-run-plan.test.ts index c192509197e..3e1736000aa 100644 --- a/src/node-host/invoke-system-run-plan.test.ts +++ b/src/node-host/invoke-system-run-plan.test.ts @@ -246,6 +246,38 @@ describe("hardenApprovedExecutionPaths", () => { initialBody: 'console.log("SAFE");\n', expectedArgvIndex: 1, }, + { + name: "tsx direct file", + binName: "tsx", + argv: ["tsx", "./run.ts"], + scriptName: "run.ts", + initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 1, + }, + { + name: "jiti direct file", + binName: "jiti", + argv: ["jiti", "./run.ts"], + scriptName: "run.ts", + initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 1, + }, + { + name: "ts-node direct file", + binName: "ts-node", + argv: ["ts-node", "./run.ts"], + scriptName: "run.ts", + initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 1, + }, + { + name: "vite-node direct file", + binName: "vite-node", + argv: ["vite-node", "./run.ts"], + scriptName: "run.ts", + initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 1, + }, { name: "bun direct file", binName: "bun", @@ -387,4 +419,26 @@ describe("hardenApprovedExecutionPaths", () => { }, }); }); + + it("rejects tsx eval invocations that do not bind a concrete file", () => { + withFakeRuntimeBin({ + binName: "tsx", + run: () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-tsx-eval-")); + try { + const prepared = buildSystemRunApprovalPlan({ + command: ["tsx", "--eval", "console.log('SAFE')"], + cwd: tmp, + }); + expect(prepared).toEqual({ + ok: false, + message: + "SYSTEM_RUN_DENIED: approval cannot safely bind this interpreter/runtime command", + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + }); + }); }); diff --git a/src/node-host/invoke-system-run-plan.ts b/src/node-host/invoke-system-run-plan.ts index 606d50e7653..1b46312c3a1 100644 --- a/src/node-host/invoke-system-run-plan.ts +++ b/src/node-host/invoke-system-run-plan.ts @@ -33,6 +33,15 @@ const MUTABLE_ARGV1_INTERPRETER_PATTERNS = [ /^ruby$/, ] as const; +const GENERIC_MUTABLE_SCRIPT_RUNNERS = new Set([ + "esno", + "jiti", + "ts-node", + "ts-node-esm", + "tsx", + "vite-node", +]); + const BUN_SUBCOMMANDS = new Set([ "add", "audit", @@ -409,6 +418,10 @@ function resolveDenoRunScriptOperandIndex(params: { }); } +function isMutableScriptRunner(executable: string): boolean { + return GENERIC_MUTABLE_SCRIPT_RUNNERS.has(executable) || isInterpreterLikeSafeBin(executable); +} + function resolveMutableFileOperandIndex(argv: string[], cwd: string | undefined): number | null { const unwrapped = unwrapArgvForMutableOperand(argv); const executable = normalizeExecutableToken(unwrapped.argv[0] ?? ""); @@ -443,7 +456,7 @@ function resolveMutableFileOperandIndex(argv: string[], cwd: string | undefined) return unwrapped.baseIndex + denoIndex; } } - if (!isInterpreterLikeSafeBin(executable)) { + if (!isMutableScriptRunner(executable)) { return null; } const genericIndex = resolveGenericInterpreterScriptOperandIndex({ @@ -468,10 +481,10 @@ function requiresStableInterpreterApprovalBindingWithShellCommand(params: { if ((POSIX_SHELL_WRAPPERS as ReadonlySet).has(executable)) { return false; } - return isInterpreterLikeSafeBin(executable); + return isMutableScriptRunner(executable); } -function resolveMutableFileOperandSnapshotSync(params: { +export function resolveMutableFileOperandSnapshotSync(params: { argv: string[]; cwd: string | undefined; shellCommand: string | null; diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index c4e5bc345f6..d183f9087c3 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -109,27 +109,50 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { }; } - function createRuntimeScriptOperandFixture(params: { tmp: string; runtime: "bun" | "deno" }): { + function createRuntimeScriptOperandFixture(params: { + tmp: string; + runtime: "bun" | "deno" | "jiti" | "tsx"; + }): { 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', - }; + const initialBody = 'console.log("SAFE");\n'; + const changedBody = 'console.log("PWNED");\n'; + switch (params.runtime) { + case "bun": + return { + command: ["bun", "run", "./run.ts"], + scriptPath, + initialBody, + changedBody, + }; + case "deno": + return { + command: ["deno", "run", "-A", "--allow-read", "--", "./run.ts"], + scriptPath, + initialBody, + changedBody, + }; + case "jiti": + return { + command: ["jiti", "./run.ts"], + scriptPath, + initialBody, + changedBody, + }; + case "tsx": + return { + command: ["tsx", "./run.ts"], + scriptPath, + initialBody, + changedBody, + }; } - return { - command: ["deno", "run", "-A", "--allow-read", "--", "./run.ts"], - scriptPath, - initialBody: 'console.log("SAFE");\n', - changedBody: 'console.log("PWNED");\n', - }; + const unsupportedRuntime: never = params.runtime; + throw new Error(`unsupported runtime fixture: ${String(unsupportedRuntime)}`); } function buildNestedEnvShellCommand(params: { depth: number; payload: string }): string[] { @@ -223,7 +246,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { } async function withFakeRuntimeOnPath(params: { - runtime: "bun" | "deno"; + runtime: "bun" | "deno" | "jiti" | "tsx"; run: () => Promise; }): Promise { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `openclaw-${params.runtime}-path-`)); @@ -842,7 +865,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { } }); - for (const runtime of ["bun", "deno"] as const) { + for (const runtime of ["bun", "deno", "tsx", "jiti"] as const) { it(`denies approval-based execution when a ${runtime} script operand changes after approval`, async () => { await withFakeRuntimeOnPath({ runtime, @@ -926,6 +949,50 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { }); } + it("denies approval-based execution when tsx is missing a required mutable script binding", async () => { + await withFakeRuntimeOnPath({ + runtime: "tsx", + run: async () => { + const tmp = fs.mkdtempSync( + path.join(os.tmpdir(), "openclaw-approval-tsx-missing-binding-"), + ); + const fixture = createRuntimeScriptOperandFixture({ tmp, runtime: "tsx" }); + 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 planWithoutBinding = { ...prepared.plan }; + delete planWithoutBinding.mutableFileOperand; + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + command: prepared.plan.argv, + rawCommand: prepared.plan.commandText, + systemRunPlan: planWithoutBinding, + cwd: prepared.plan.cwd ?? tmp, + approved: true, + security: "full", + ask: "off", + }); + + expect(runCommand).not.toHaveBeenCalled(); + expectInvokeErrorMessage(sendInvokeResult, { + message: "SYSTEM_RUN_DENIED: approval missing script operand binding", + exact: true, + }); + } 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 () => { diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index 3ed2a30d188..3730e3b2824 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -29,6 +29,7 @@ import { hardenApprovedExecutionPaths, revalidateApprovedCwdSnapshot, revalidateApprovedMutableFileOperand, + resolveMutableFileOperandSnapshotSync, type ApprovedCwdSnapshot, } from "./invoke-system-run-plan.js"; import type { @@ -98,6 +99,8 @@ type SystemRunPolicyPhase = SystemRunParsePhase & { const safeBinTrustedDirWarningCache = new Set(); const APPROVAL_CWD_DRIFT_DENIED_MESSAGE = "SYSTEM_RUN_DENIED: approval cwd changed before execution"; +const APPROVAL_SCRIPT_OPERAND_BINDING_DENIED_MESSAGE = + "SYSTEM_RUN_DENIED: approval missing script operand binding"; const APPROVAL_SCRIPT_OPERAND_DRIFT_DENIED_MESSAGE = "SYSTEM_RUN_DENIED: approval script operand changed before execution"; @@ -385,6 +388,29 @@ async function executeSystemRunPhase( }); return; } + const expectedMutableFileOperand = phase.approvalPlan + ? resolveMutableFileOperandSnapshotSync({ + argv: phase.argv, + cwd: phase.cwd, + shellCommand: phase.shellPayload, + }) + : null; + if (expectedMutableFileOperand && !expectedMutableFileOperand.ok) { + logWarn(`security: system.run approval script binding blocked (runId=${phase.runId})`); + await sendSystemRunDenied(opts, phase.execution, { + reason: "approval-required", + message: expectedMutableFileOperand.message, + }); + return; + } + if (expectedMutableFileOperand?.snapshot && !phase.approvalPlan?.mutableFileOperand) { + logWarn(`security: system.run approval script binding missing (runId=${phase.runId})`); + await sendSystemRunDenied(opts, phase.execution, { + reason: "approval-required", + message: APPROVAL_SCRIPT_OPERAND_BINDING_DENIED_MESSAGE, + }); + return; + } if ( phase.approvalPlan?.mutableFileOperand && !revalidateApprovedMutableFileOperand({