| import fs from "node:fs"; |
| import os from "node:os"; |
| import path from "node:path"; |
| import { describe, expect, it } from "vitest"; |
| import { formatExecCommand } from "../infra/system-run-command.js"; |
| import { |
| buildSystemRunApprovalPlan, |
| hardenApprovedExecutionPaths, |
| resolveMutableFileOperandSnapshotSync, |
| } from "./invoke-system-run-plan.js"; |
|
|
| type PathTokenSetup = { |
| expected: string; |
| }; |
|
|
| type HardeningCase = { |
| name: string; |
| mode: "build-plan" | "harden"; |
| argv: string[]; |
| shellCommand?: string | null; |
| withPathToken?: boolean; |
| expectedArgv: (ctx: { pathToken: PathTokenSetup | null }) => string[]; |
| expectedArgvChanged?: boolean; |
| expectedCmdText?: string; |
| checkRawCommandMatchesArgv?: boolean; |
| expectedCommandPreview?: string | null; |
| }; |
|
|
| type ScriptOperandFixture = { |
| command: string[]; |
| scriptPath: string; |
| initialBody: string; |
| expectedArgvIndex: number; |
| }; |
|
|
| type RuntimeFixture = { |
| name: string; |
| argv: string[]; |
| scriptName: string; |
| initialBody: string; |
| expectedArgvIndex: number; |
| binName?: string; |
| binNames?: string[]; |
| }; |
|
|
| type UnsafeRuntimeInvocationCase = { |
| name: string; |
| binName: string; |
| tmpPrefix: string; |
| command: string[]; |
| setup?: (tmp: string) => void; |
| }; |
|
|
| 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") { |
| return { |
| command: [process.execPath, "./run.js"], |
| scriptPath: path.join(tmp, "run.js"), |
| initialBody: 'console.log("SAFE");\n', |
| expectedArgvIndex: 1, |
| }; |
| } |
| return { |
| command: ["/bin/sh", "./run.sh"], |
| scriptPath: path.join(tmp, "run.sh"), |
| initialBody: "#!/bin/sh\necho SAFE\n", |
| expectedArgvIndex: 1, |
| }; |
| } |
|
|
| function writeFakeRuntimeBin(binDir: string, binName: string) { |
| const runtimePath = |
| process.platform === "win32" ? path.join(binDir, `${binName}.cmd`) : path.join(binDir, binName); |
| const runtimeBody = |
| process.platform === "win32" ? "@echo off\r\nexit /b 0\r\n" : "#!/bin/sh\nexit 0\n"; |
| fs.writeFileSync(runtimePath, runtimeBody, { mode: 0o755 }); |
| if (process.platform !== "win32") { |
| fs.chmodSync(runtimePath, 0o755); |
| } |
| } |
|
|
| function withFakeRuntimeBin<T>(params: { binName: string; run: () => T }): T { |
| return withFakeRuntimeBins({ |
| binNames: [params.binName], |
| tmpPrefix: `openclaw-${params.binName}-bin-`, |
| run: params.run, |
| }); |
| } |
|
|
| function withFakeRuntimeBins<T>(params: { |
| binNames: string[]; |
| tmpPrefix?: string; |
| run: () => T; |
| }): T { |
| const tmp = fs.mkdtempSync(path.join(os.tmpdir(), params.tmpPrefix ?? "openclaw-runtime-bins-")); |
| const binDir = path.join(tmp, "bin"); |
| fs.mkdirSync(binDir, { recursive: true }); |
| for (const binName of params.binNames) { |
| writeFakeRuntimeBin(binDir, binName); |
| } |
| 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 }); |
| } |
| } |
|
|
| function expectMutableFileOperandApprovalPlan(fixture: ScriptOperandFixture, cwd: string) { |
| const prepared = buildSystemRunApprovalPlan({ |
| command: fixture.command, |
| cwd, |
| }); |
| 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), |
| }); |
| } |
|
|
| function writeScriptOperandFixture(fixture: ScriptOperandFixture) { |
| fs.writeFileSync(fixture.scriptPath, fixture.initialBody); |
| if (process.platform !== "win32") { |
| fs.chmodSync(fixture.scriptPath, 0o755); |
| } |
| } |
|
|
| function withScriptOperandPlanFixture<T>( |
| params: { |
| tmpPrefix: string; |
| fixture?: RuntimeFixture; |
| afterWrite?: (fixture: ScriptOperandFixture, tmp: string) => void; |
| }, |
| run: (fixture: ScriptOperandFixture, tmp: string) => T, |
| ) { |
| const tmp = fs.mkdtempSync(path.join(os.tmpdir(), params.tmpPrefix)); |
| const fixture = createScriptOperandFixture(tmp, params.fixture); |
| writeScriptOperandFixture(fixture); |
| params.afterWrite?.(fixture, tmp); |
| try { |
| return run(fixture, tmp); |
| } finally { |
| fs.rmSync(tmp, { recursive: true, force: true }); |
| } |
| } |
|
|
| const DENIED_RUNTIME_APPROVAL = { |
| ok: false, |
| message: "SYSTEM_RUN_DENIED: approval cannot safely bind this interpreter/runtime command", |
| } as const; |
|
|
| function expectRuntimeApprovalDenied(command: string[], cwd: string) { |
| const prepared = buildSystemRunApprovalPlan({ command, cwd }); |
| expect(prepared).toEqual(DENIED_RUNTIME_APPROVAL); |
| } |
|
|
| const unsafeRuntimeInvocationCases: UnsafeRuntimeInvocationCase[] = [ |
| { |
| name: "rejects bun package script names that do not bind a concrete file", |
| binName: "bun", |
| tmpPrefix: "openclaw-bun-package-script-", |
| command: ["bun", "run", "dev"], |
| }, |
| { |
| name: "rejects deno eval invocations that do not bind a concrete file", |
| binName: "deno", |
| tmpPrefix: "openclaw-deno-eval-", |
| command: ["deno", "eval", "console.log('SAFE')"], |
| }, |
| { |
| name: "rejects tsx eval invocations that do not bind a concrete file", |
| binName: "tsx", |
| tmpPrefix: "openclaw-tsx-eval-", |
| command: ["tsx", "--eval", "console.log('SAFE')"], |
| }, |
| { |
| name: "rejects node inline import operands that cannot be bound to one stable file", |
| binName: "node", |
| tmpPrefix: "openclaw-node-import-inline-", |
| command: ["node", "--import=./preload.mjs", "./main.mjs"], |
| setup: (tmp) => { |
| fs.writeFileSync(path.join(tmp, "main.mjs"), 'console.log("SAFE")\n'); |
| fs.writeFileSync(path.join(tmp, "preload.mjs"), 'console.log("SAFE")\n'); |
| }, |
| }, |
| { |
| name: "rejects ruby require preloads that approval cannot bind completely", |
| binName: "ruby", |
| tmpPrefix: "openclaw-ruby-require-", |
| command: ["ruby", "-r", "attacker", "./safe.rb"], |
| setup: (tmp) => { |
| fs.writeFileSync(path.join(tmp, "safe.rb"), 'puts "SAFE"\n'); |
| }, |
| }, |
| { |
| name: "rejects ruby load-path flags that can redirect module resolution after approval", |
| binName: "ruby", |
| tmpPrefix: "openclaw-ruby-load-path-", |
| command: ["ruby", "-I.", "./safe.rb"], |
| setup: (tmp) => { |
| fs.writeFileSync(path.join(tmp, "safe.rb"), 'puts "SAFE"\n'); |
| }, |
| }, |
| { |
| name: "rejects perl module preloads that approval cannot bind completely", |
| binName: "perl", |
| tmpPrefix: "openclaw-perl-module-preload-", |
| command: ["perl", "-MPreload", "./safe.pl"], |
| setup: (tmp) => { |
| fs.writeFileSync(path.join(tmp, "safe.pl"), 'print "SAFE\\n";\n'); |
| }, |
| }, |
| { |
| name: "rejects perl load-path flags that can redirect module resolution after approval", |
| binName: "perl", |
| tmpPrefix: "openclaw-perl-load-path-", |
| command: ["perl", "-Ilib", "./safe.pl"], |
| setup: (tmp) => { |
| fs.writeFileSync(path.join(tmp, "safe.pl"), 'print "SAFE\\n";\n'); |
| }, |
| }, |
| { |
| name: "rejects perl combined preload and load-path flags", |
| binName: "perl", |
| tmpPrefix: "openclaw-perl-preload-load-path-", |
| command: ["perl", "-Ilib", "-MPreload", "./safe.pl"], |
| setup: (tmp) => { |
| fs.writeFileSync(path.join(tmp, "safe.pl"), 'print "SAFE\\n";\n'); |
| }, |
| }, |
| { |
| name: "rejects shell payloads that hide mutable interpreter scripts", |
| binName: "node", |
| tmpPrefix: "openclaw-inline-shell-node-", |
| command: ["sh", "-lc", "node ./run.js"], |
| setup: (tmp) => { |
| fs.writeFileSync(path.join(tmp, "run.js"), 'console.log("SAFE")\n'); |
| }, |
| }, |
| ]; |
|
|
| describe("hardenApprovedExecutionPaths", () => { |
| const cases: HardeningCase[] = [ |
| { |
| name: "preserves shell-wrapper argv during approval hardening", |
| mode: "build-plan", |
| argv: ["env", "sh", "-c", "echo SAFE"], |
| expectedArgv: () => ["env", "sh", "-c", "echo SAFE"], |
| expectedCmdText: 'env sh -c "echo SAFE"', |
| expectedCommandPreview: "echo SAFE", |
| }, |
| { |
| name: "preserves dispatch-wrapper argv during approval hardening", |
| mode: "harden", |
| argv: ["env", "tr", "a", "b"], |
| shellCommand: null, |
| expectedArgv: () => ["env", "tr", "a", "b"], |
| expectedArgvChanged: false, |
| }, |
| { |
| name: "pins direct PATH-token executable during approval hardening", |
| mode: "harden", |
| argv: ["poccmd", "SAFE"], |
| shellCommand: null, |
| withPathToken: true, |
| expectedArgv: ({ pathToken }) => [pathToken!.expected, "SAFE"], |
| expectedArgvChanged: true, |
| }, |
| { |
| name: "preserves env-wrapper PATH-token argv during approval hardening", |
| mode: "harden", |
| argv: ["env", "poccmd", "SAFE"], |
| shellCommand: null, |
| withPathToken: true, |
| expectedArgv: () => ["env", "poccmd", "SAFE"], |
| expectedArgvChanged: false, |
| }, |
| { |
| name: "rawCommand matches hardened argv after executable path pinning", |
| mode: "build-plan", |
| argv: ["poccmd", "hello"], |
| withPathToken: true, |
| expectedArgv: ({ pathToken }) => [pathToken!.expected, "hello"], |
| checkRawCommandMatchesArgv: true, |
| expectedCommandPreview: null, |
| }, |
| { |
| name: "stores full approval text and preview for path-qualified env wrappers", |
| mode: "build-plan", |
| argv: ["./env", "sh", "-c", "echo SAFE"], |
| expectedArgv: () => ["./env", "sh", "-c", "echo SAFE"], |
| expectedCmdText: './env sh -c "echo SAFE"', |
| checkRawCommandMatchesArgv: true, |
| expectedCommandPreview: "echo SAFE", |
| }, |
| ]; |
|
|
| for (const testCase of cases) { |
| it.runIf(process.platform !== "win32")(testCase.name, () => { |
| const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-hardening-")); |
| const oldPath = process.env.PATH; |
| let pathToken: PathTokenSetup | null = null; |
| if (testCase.withPathToken) { |
| const binDir = path.join(tmp, "bin"); |
| fs.mkdirSync(binDir, { recursive: true }); |
| const link = path.join(binDir, "poccmd"); |
| fs.symlinkSync("/bin/echo", link); |
| pathToken = { expected: fs.realpathSync(link) }; |
| process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`; |
| } |
| try { |
| if (testCase.mode === "build-plan") { |
| const prepared = buildSystemRunApprovalPlan({ |
| command: testCase.argv, |
| cwd: tmp, |
| }); |
| expect(prepared.ok).toBe(true); |
| if (!prepared.ok) { |
| throw new Error("unreachable"); |
| } |
| expect(prepared.plan.argv).toEqual(testCase.expectedArgv({ pathToken })); |
| if (testCase.expectedCmdText) { |
| expect(prepared.plan.commandText).toBe(testCase.expectedCmdText); |
| } |
| if (testCase.checkRawCommandMatchesArgv) { |
| expect(prepared.plan.commandText).toBe(formatExecCommand(prepared.plan.argv)); |
| } |
| if ("expectedCommandPreview" in testCase) { |
| expect(prepared.plan.commandPreview ?? null).toBe(testCase.expectedCommandPreview); |
| } |
| return; |
| } |
|
|
| const hardened = hardenApprovedExecutionPaths({ |
| approvedByAsk: true, |
| argv: testCase.argv, |
| shellCommand: testCase.shellCommand ?? null, |
| cwd: tmp, |
| }); |
| expect(hardened.ok).toBe(true); |
| if (!hardened.ok) { |
| throw new Error("unreachable"); |
| } |
| expect(hardened.argv).toEqual(testCase.expectedArgv({ pathToken })); |
| if (typeof testCase.expectedArgvChanged === "boolean") { |
| expect(hardened.argvChanged).toBe(testCase.expectedArgvChanged); |
| } |
| } finally { |
| if (testCase.withPathToken) { |
| if (oldPath === undefined) { |
| delete process.env.PATH; |
| } else { |
| process.env.PATH = oldPath; |
| } |
| } |
| fs.rmSync(tmp, { recursive: true, force: true }); |
| } |
| }); |
| } |
|
|
| const mutableOperandCases: RuntimeFixture[] = [ |
| { |
| name: "python flagged file", |
| binName: "python3", |
| argv: ["python3", "-B", "./run.py"], |
| scriptName: "run.py", |
| initialBody: 'print("SAFE")\n', |
| expectedArgvIndex: 2, |
| }, |
| { |
| name: "lua direct file", |
| binName: "lua", |
| argv: ["lua", "./run.lua"], |
| scriptName: "run.lua", |
| initialBody: 'print("SAFE")\n', |
| expectedArgvIndex: 1, |
| }, |
| { |
| name: "pypy direct file", |
| binName: "pypy", |
| argv: ["pypy", "./run.py"], |
| scriptName: "run.py", |
| initialBody: 'print("SAFE")\n', |
| expectedArgvIndex: 1, |
| }, |
| { |
| name: "versioned node alias file", |
| binName: "node20", |
| argv: ["node20", "./run.js"], |
| scriptName: "run.js", |
| 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", |
| 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, |
| }, |
| { |
| name: "bun test file", |
| binName: "bun", |
| argv: ["bun", "test", "./run.test.ts"], |
| scriptName: "run.test.ts", |
| initialBody: 'console.log("SAFE");\n', |
| expectedArgvIndex: 2, |
| }, |
| { |
| name: "deno test file", |
| binName: "deno", |
| argv: ["deno", "test", "./run.test.ts"], |
| scriptName: "run.test.ts", |
| initialBody: 'console.log("SAFE");\n', |
| expectedArgvIndex: 2, |
| }, |
| { |
| name: "pnpm exec tsx file", |
| argv: ["pnpm", "exec", "tsx", "./run.ts"], |
| scriptName: "run.ts", |
| initialBody: 'console.log("SAFE");\n', |
| expectedArgvIndex: 3, |
| }, |
| { |
| name: "pnpm reporter exec tsx file", |
| argv: ["pnpm", "--reporter", "silent", "exec", "tsx", "./run.ts"], |
| scriptName: "run.ts", |
| initialBody: 'console.log("SAFE");\n', |
| expectedArgvIndex: 5, |
| }, |
| { |
| name: "pnpm reporter-equals exec tsx file", |
| argv: ["pnpm", "--reporter=silent", "exec", "tsx", "./run.ts"], |
| scriptName: "run.ts", |
| initialBody: 'console.log("SAFE");\n', |
| expectedArgvIndex: 4, |
| }, |
| { |
| name: "pnpm js shim exec tsx file", |
| argv: ["./pnpm.js", "exec", "tsx", "./run.ts"], |
| scriptName: "run.ts", |
| initialBody: 'console.log("SAFE");\n', |
| expectedArgvIndex: 3, |
| }, |
| { |
| name: "pnpm exec double-dash tsx file", |
| argv: ["pnpm", "exec", "--", "tsx", "./run.ts"], |
| scriptName: "run.ts", |
| initialBody: 'console.log("SAFE");\n', |
| expectedArgvIndex: 4, |
| }, |
| { |
| name: "pnpm node file", |
| argv: ["pnpm", "node", "./run.js"], |
| scriptName: "run.js", |
| initialBody: 'console.log("SAFE");\n', |
| expectedArgvIndex: 2, |
| binNames: ["pnpm", "node"], |
| }, |
| { |
| name: "pnpm node double-dash file", |
| argv: ["pnpm", "node", "--", "./run.js"], |
| scriptName: "run.js", |
| initialBody: 'console.log("SAFE");\n', |
| expectedArgvIndex: 3, |
| binNames: ["pnpm", "node"], |
| }, |
| { |
| name: "npx tsx file", |
| argv: ["npx", "tsx", "./run.ts"], |
| scriptName: "run.ts", |
| initialBody: 'console.log("SAFE");\n', |
| expectedArgvIndex: 2, |
| }, |
| { |
| name: "bunx tsx file", |
| argv: ["bunx", "tsx", "./run.ts"], |
| scriptName: "run.ts", |
| initialBody: 'console.log("SAFE");\n', |
| expectedArgvIndex: 2, |
| }, |
| { |
| name: "npm exec tsx file", |
| argv: ["npm", "exec", "--", "tsx", "./run.ts"], |
| scriptName: "run.ts", |
| initialBody: 'console.log("SAFE");\n', |
| expectedArgvIndex: 4, |
| }, |
| ]; |
|
|
| for (const runtimeCase of mutableOperandCases) { |
| it(`captures mutable ${runtimeCase.name} operands in approval plans`, () => { |
| const binNames = |
| runtimeCase.binNames ?? |
| (runtimeCase.binName ? [runtimeCase.binName] : ["bunx", "pnpm", "npm", "npx", "tsx"]); |
| withFakeRuntimeBins({ |
| binNames, |
| run: () => { |
| withScriptOperandPlanFixture( |
| { |
| tmpPrefix: "openclaw-approval-script-plan-", |
| fixture: runtimeCase, |
| afterWrite: (fixture, tmp) => { |
| const executablePath = fixture.command[0]; |
| if (executablePath?.endsWith("pnpm.js")) { |
| const shimPath = path.join(tmp, "pnpm.js"); |
| fs.writeFileSync(shimPath, "#!/usr/bin/env node\nconsole.log('shim')\n"); |
| fs.chmodSync(shimPath, 0o755); |
| } |
| }, |
| }, |
| (fixture, tmp) => { |
| expectMutableFileOperandApprovalPlan(fixture, tmp); |
| }, |
| ); |
| }, |
| }); |
| }); |
| } |
|
|
| it("captures mutable shell script operands in approval plans", () => { |
| withScriptOperandPlanFixture( |
| { |
| tmpPrefix: "openclaw-approval-script-plan-", |
| }, |
| (fixture, tmp) => { |
| expectMutableFileOperandApprovalPlan(fixture, tmp); |
| }, |
| ); |
| }); |
|
|
| for (const testCase of unsafeRuntimeInvocationCases) { |
| it(testCase.name, () => { |
| withFakeRuntimeBin({ |
| binName: testCase.binName, |
| run: () => { |
| const tmp = fs.mkdtempSync(path.join(os.tmpdir(), testCase.tmpPrefix)); |
| try { |
| testCase.setup?.(tmp); |
| expectRuntimeApprovalDenied(testCase.command, tmp); |
| } finally { |
| fs.rmSync(tmp, { recursive: true, force: true }); |
| } |
| }, |
| }); |
| }); |
| } |
|
|
| it("captures the real shell script operand after value-taking shell flags", () => { |
| const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-option-value-")); |
| try { |
| const scriptPath = path.join(tmp, "run.sh"); |
| fs.writeFileSync(scriptPath, "#!/bin/sh\necho SAFE\n"); |
| fs.writeFileSync(path.join(tmp, "errexit"), "decoy\n"); |
| const snapshot = resolveMutableFileOperandSnapshotSync({ |
| argv: ["/bin/bash", "-o", "errexit", "./run.sh"], |
| cwd: tmp, |
| shellCommand: null, |
| }); |
| expect(snapshot).toEqual({ |
| ok: true, |
| snapshot: { |
| argvIndex: 3, |
| path: fs.realpathSync(scriptPath), |
| sha256: expect.any(String), |
| }, |
| }); |
| } finally { |
| fs.rmSync(tmp, { recursive: true, force: true }); |
| } |
| }); |
| }); |
|
|