fix(security): harden node exec approvals against symlink rebind

This commit is contained in:
Peter Steinberger
2026-02-26 21:47:38 +01:00
parent 611dff985d
commit 78a7ff2d50
15 changed files with 489 additions and 43 deletions

View File

@@ -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 () => {

View File

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