From cf3a479bd1204f62eef7dd82b4aa328749ae6c91 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 05:59:32 +0000 Subject: [PATCH] fix(node-host): bind bun and deno approval scripts --- CHANGELOG.md | 1 + src/node-host/invoke-system-run-plan.test.ts | 162 ++++++++++++- src/node-host/invoke-system-run-plan.ts | 233 ++++++++++++++++++- src/node-host/invoke-system-run.test.ts | 131 +++++++++++ 4 files changed, 515 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 399c916ae09..a58cab6915f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/node-host/invoke-system-run-plan.test.ts b/src/node-host/invoke-system-run-plan.test.ts index 07b60c160fe..8b10bceb2c0 100644 --- a/src/node-host/invoke-system-run-plan.test.ts +++ b/src/node-host/invoke-system-run-plan.test.ts @@ -24,27 +24,68 @@ type HardeningCase = { checkRawCommandMatchesArgv?: boolean; }; -function createScriptOperandFixture(tmp: string): { +type ScriptOperandFixture = { command: string[]; scriptPath: string; initialBody: string; -} { - if (process.platform === "win32") { - const scriptPath = path.join(tmp, "run.js"); + 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: [process.execPath, "./run.js"], - scriptPath, - initialBody: 'console.log("SAFE");\n', + command: fixture.argv, + scriptPath: path.join(tmp, fixture.scriptName), + initialBody: fixture.initialBody, + expectedArgvIndex: fixture.expectedArgvIndex, + }; + } + if (process.platform === "win32") { + return { + command: [process.execPath, "./run.js"], + 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(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,7 +265,7 @@ 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), }); @@ -175,4 +273,48 @@ describe("hardenApprovedExecutionPaths", () => { 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 }); + } + }, + }); + }); }); diff --git a/src/node-host/invoke-system-run-plan.ts b/src/node-host/invoke-system-run-plan.ts index c35bf748667..111664713d9 100644 --- a/src/node-host/invoke-system-run-plan.ts +++ b/src/node-host/invoke-system-run-plan.ts @@ -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; +}): 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; +}): 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 }; } diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 9295460a23a..2d78ec46500 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -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(params: { + runtime: "bun" | "deno"; + run: () => Promise; + }): Promise { + 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 () => {