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