mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-01 07:20:21 +00:00
fix(security): harden node exec approvals against symlink rebind
This commit is contained in:
@@ -28,6 +28,35 @@ const callGateway = vi.fn(async (opts: NodeInvokeCall) => {
|
||||
};
|
||||
}
|
||||
if (opts.method === "node.invoke") {
|
||||
const command = opts.params?.command;
|
||||
if (command === "system.run.prepare") {
|
||||
const params = (opts.params?.params ?? {}) as {
|
||||
command?: unknown[];
|
||||
rawCommand?: unknown;
|
||||
cwd?: unknown;
|
||||
agentId?: unknown;
|
||||
};
|
||||
const argv = Array.isArray(params.command)
|
||||
? params.command.map((entry) => String(entry))
|
||||
: [];
|
||||
const rawCommand =
|
||||
typeof params.rawCommand === "string" && params.rawCommand.trim().length > 0
|
||||
? params.rawCommand
|
||||
: null;
|
||||
return {
|
||||
payload: {
|
||||
cmdText: rawCommand ?? argv.join(" "),
|
||||
plan: {
|
||||
version: 2,
|
||||
argv,
|
||||
cwd: typeof params.cwd === "string" ? params.cwd : null,
|
||||
rawCommand,
|
||||
agentId: typeof params.agentId === "string" ? params.agentId : null,
|
||||
sessionKey: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
payload: {
|
||||
stdout: "",
|
||||
@@ -80,8 +109,16 @@ vi.mock("../config/config.js", () => ({
|
||||
describe("nodes-cli coverage", () => {
|
||||
let registerNodesCli: (program: Command) => void;
|
||||
|
||||
const getNodeInvokeCall = () =>
|
||||
callGateway.mock.calls.find((call) => call[0]?.method === "node.invoke")?.[0] as NodeInvokeCall;
|
||||
const getNodeInvokeCall = () => {
|
||||
const nodeInvokeCalls = callGateway.mock.calls
|
||||
.map((call) => call[0])
|
||||
.filter((entry): entry is NodeInvokeCall => entry?.method === "node.invoke");
|
||||
const last = nodeInvokeCalls.at(-1);
|
||||
if (!last) {
|
||||
throw new Error("expected node.invoke call");
|
||||
}
|
||||
return last;
|
||||
};
|
||||
|
||||
const getApprovalRequestCall = () =>
|
||||
callGateway.mock.calls.find((call) => call[0]?.method === "exec.approval.request")?.[0] as {
|
||||
@@ -135,6 +172,7 @@ describe("nodes-cli coverage", () => {
|
||||
expect(invoke?.params?.command).toBe("system.run");
|
||||
expect(invoke?.params?.params).toEqual({
|
||||
command: ["echo", "hi"],
|
||||
rawCommand: null,
|
||||
cwd: "/tmp",
|
||||
env: { FOO: "bar" },
|
||||
timeoutMs: 1200,
|
||||
@@ -147,6 +185,14 @@ describe("nodes-cli coverage", () => {
|
||||
expect(invoke?.params?.timeoutMs).toBe(5000);
|
||||
const approval = getApprovalRequestCall();
|
||||
expect(approval?.params?.["commandArgv"]).toEqual(["echo", "hi"]);
|
||||
expect(approval?.params?.["systemRunPlanV2"]).toEqual({
|
||||
version: 2,
|
||||
argv: ["echo", "hi"],
|
||||
cwd: "/tmp",
|
||||
rawCommand: null,
|
||||
agentId: "main",
|
||||
sessionKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("invokes system.run with raw command", async () => {
|
||||
@@ -174,6 +220,14 @@ describe("nodes-cli coverage", () => {
|
||||
});
|
||||
const approval = getApprovalRequestCall();
|
||||
expect(approval?.params?.["commandArgv"]).toEqual(["/bin/sh", "-lc", "echo hi"]);
|
||||
expect(approval?.params?.["systemRunPlanV2"]).toEqual({
|
||||
version: 2,
|
||||
argv: ["/bin/sh", "-lc", "echo hi"],
|
||||
cwd: null,
|
||||
rawCommand: "echo hi",
|
||||
agentId: "main",
|
||||
sessionKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("invokes system.notify with provided fields", async () => {
|
||||
|
||||
@@ -7,12 +7,14 @@ import {
|
||||
type ExecApprovalsFile,
|
||||
type ExecAsk,
|
||||
type ExecSecurity,
|
||||
type SystemRunApprovalPlanV2,
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
resolveExecApprovalsFromFile,
|
||||
} from "../../infra/exec-approvals.js";
|
||||
import { buildNodeShellCommand } from "../../infra/node-shell.js";
|
||||
import { applyPathPrepend } from "../../infra/path-prepend.js";
|
||||
import { normalizeSystemRunApprovalPlanV2 } from "../../infra/system-run-approval-binding.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { parseEnvPairs, parseTimeoutMs } from "../nodes-run.js";
|
||||
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
||||
@@ -42,6 +44,22 @@ type ExecDefaults = {
|
||||
safeBins?: string[];
|
||||
};
|
||||
|
||||
function parsePreparedRunPlan(payload: unknown): {
|
||||
cmdText: string;
|
||||
plan: SystemRunApprovalPlanV2;
|
||||
} {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
throw new Error("invalid system.run.prepare response");
|
||||
}
|
||||
const raw = payload as { cmdText?: unknown; plan?: unknown };
|
||||
const cmdText = typeof raw.cmdText === "string" ? raw.cmdText.trim() : "";
|
||||
const plan = normalizeSystemRunApprovalPlanV2(raw.plan);
|
||||
if (!cmdText || !plan) {
|
||||
throw new Error("invalid system.run.prepare response");
|
||||
}
|
||||
return { cmdText, plan };
|
||||
}
|
||||
|
||||
function normalizeExecSecurity(value?: string | null): ExecSecurity | null {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === "deny" || normalized === "allowlist" || normalized === "full") {
|
||||
@@ -192,6 +210,20 @@ export function registerNodesInvokeCommands(nodes: Command) {
|
||||
applyPathPrepend(nodeEnv, execDefaults?.pathPrepend, { requireExisting: true });
|
||||
}
|
||||
|
||||
const prepareResponse = (await callGatewayCli("node.invoke", opts, {
|
||||
nodeId,
|
||||
command: "system.run.prepare",
|
||||
params: {
|
||||
command: argv,
|
||||
rawCommand,
|
||||
cwd: opts.cwd,
|
||||
agentId,
|
||||
},
|
||||
idempotencyKey: `prepare-${randomIdempotencyKey()}`,
|
||||
})) as { payload?: unknown } | null;
|
||||
const prepared = parsePreparedRunPlan(prepareResponse?.payload);
|
||||
const approvalPlan = prepared.plan;
|
||||
|
||||
let approvedByAsk = false;
|
||||
let approvalDecision: "allow-once" | "allow-always" | null = null;
|
||||
const configuredSecurity = normalizeExecSecurity(execDefaults?.security) ?? "allowlist";
|
||||
@@ -251,16 +283,17 @@ export function registerNodesInvokeCommands(nodes: Command) {
|
||||
opts,
|
||||
{
|
||||
id: approvalId,
|
||||
command: rawCommand ?? argv.join(" "),
|
||||
commandArgv: argv,
|
||||
cwd: opts.cwd,
|
||||
command: prepared.cmdText,
|
||||
commandArgv: approvalPlan.argv,
|
||||
systemRunPlanV2: approvalPlan,
|
||||
cwd: approvalPlan.cwd,
|
||||
nodeId,
|
||||
host: "node",
|
||||
security: hostSecurity,
|
||||
ask: hostAsk,
|
||||
agentId,
|
||||
agentId: approvalPlan.agentId ?? agentId,
|
||||
resolvedPath: undefined,
|
||||
sessionKey: undefined,
|
||||
sessionKey: approvalPlan.sessionKey ?? undefined,
|
||||
timeoutMs: approvalTimeoutMs,
|
||||
},
|
||||
{ transportTimeoutMs },
|
||||
@@ -296,19 +329,21 @@ export function registerNodesInvokeCommands(nodes: Command) {
|
||||
nodeId,
|
||||
command: "system.run",
|
||||
params: {
|
||||
command: argv,
|
||||
cwd: opts.cwd,
|
||||
command: approvalPlan.argv,
|
||||
rawCommand: approvalPlan.rawCommand,
|
||||
cwd: approvalPlan.cwd,
|
||||
env: nodeEnv,
|
||||
timeoutMs,
|
||||
needsScreenRecording: opts.needsScreenRecording === true,
|
||||
},
|
||||
idempotencyKey: String(opts.idempotencyKey ?? randomIdempotencyKey()),
|
||||
};
|
||||
if (agentId) {
|
||||
(invokeParams.params as Record<string, unknown>).agentId = agentId;
|
||||
if (approvalPlan.agentId ?? agentId) {
|
||||
(invokeParams.params as Record<string, unknown>).agentId =
|
||||
approvalPlan.agentId ?? agentId;
|
||||
}
|
||||
if (rawCommand) {
|
||||
(invokeParams.params as Record<string, unknown>).rawCommand = rawCommand;
|
||||
if (approvalPlan.sessionKey) {
|
||||
(invokeParams.params as Record<string, unknown>).sessionKey = approvalPlan.sessionKey;
|
||||
}
|
||||
(invokeParams.params as Record<string, unknown>).approved = approvedByAsk;
|
||||
if (approvalDecision) {
|
||||
|
||||
Reference in New Issue
Block a user