fix(node-host): bind bun and deno approval scripts

This commit is contained in:
Peter Steinberger 2026-03-09 05:59:32 +00:00
parent fd902b0651
commit cf3a479bd1
4 changed files with 515 additions and 12 deletions

View File

@ -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

View File

@ -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<T>(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 });
}
},
});
});
});

View File

@ -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<string>;
}): 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<string>;
}): 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 };
}

View File

@ -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<T>(params: {
runtime: "bun" | "deno";
run: () => Promise<T>;
}): Promise<T> {
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 () => {