mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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
This commit is contained in:
94
pnpm-lock.yaml
generated
94
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>).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;
|
||||
|
||||
@@ -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<T>(params: {
|
||||
runtime: "bun" | "deno";
|
||||
runtime: "bun" | "deno" | "jiti" | "tsx";
|
||||
run: () => Promise<T>;
|
||||
}): Promise<T> {
|
||||
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 () => {
|
||||
|
||||
@@ -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<string>();
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user