mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-20 06:20:55 +00:00
* Gateway: treat scope-limited probe RPC as degraded * Docs: clarify gateway probe degraded scope output * test: fix CI type regressions in gateway and outbound suites * Tests: fix Node24 diffs theme loading and Windows assertions * Tests: fix extension typing after main rebase * Tests: fix Windows CI regressions after rebase * Tests: normalize executable path assertions on Windows * Tests: remove duplicate gateway daemon result alias * Tests: stabilize Windows approval path assertions * Tests: fix Discord rate-limit startup fixture typing * Tests: use Windows-friendly relative exec fixtures --------- Co-authored-by: Mainframe <mainframe@MainfraacStudio.localdomain>
645 lines
19 KiB
TypeScript
645 lines
19 KiB
TypeScript
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[];
|
|
skipOnWin32?: boolean;
|
|
};
|
|
|
|
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,
|
|
skipOnWin32: true,
|
|
},
|
|
{
|
|
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`, () => {
|
|
if (runtimeCase.skipOnWin32 && process.platform === "win32") {
|
|
return;
|
|
}
|
|
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 });
|
|
}
|
|
});
|
|
});
|