mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 16:41:23 +00:00
fix(security): bind system.run approvals to argv identity
This commit is contained in:
@@ -6,6 +6,7 @@ const RESOLVED_ENTRY_GRACE_MS = 15_000;
|
||||
|
||||
export type ExecApprovalRequestPayload = {
|
||||
command: string;
|
||||
commandArgv?: string[] | null;
|
||||
cwd?: string | null;
|
||||
nodeId?: string | null;
|
||||
host?: string | null;
|
||||
|
||||
@@ -13,13 +13,14 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
|
||||
},
|
||||
};
|
||||
|
||||
function makeRecord(command: string): ExecApprovalRecord {
|
||||
function makeRecord(command: string, commandArgv?: string[] | null): ExecApprovalRecord {
|
||||
return {
|
||||
id: "approval-1",
|
||||
request: {
|
||||
host: "node",
|
||||
nodeId: "node-1",
|
||||
command,
|
||||
commandArgv: commandArgv ?? null,
|
||||
cwd: null,
|
||||
agentId: null,
|
||||
sessionKey: null,
|
||||
@@ -139,6 +140,64 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
|
||||
});
|
||||
expectAllowOnceForwardingResult(result);
|
||||
});
|
||||
|
||||
test("rejects trailing-space argv mismatch against legacy command-only approval", () => {
|
||||
const result = sanitizeSystemRunParamsForForwarding({
|
||||
rawParams: {
|
||||
command: ["runner "],
|
||||
runId: "approval-1",
|
||||
approved: true,
|
||||
approvalDecision: "allow-once",
|
||||
},
|
||||
nodeId: "node-1",
|
||||
client,
|
||||
execApprovalManager: manager(makeRecord("runner")),
|
||||
nowMs: now,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
expect(result.message).toContain("approval id does not match request");
|
||||
expect(result.details?.code).toBe("APPROVAL_REQUEST_MISMATCH");
|
||||
});
|
||||
|
||||
test("enforces commandArgv identity when approval includes argv binding", () => {
|
||||
const result = sanitizeSystemRunParamsForForwarding({
|
||||
rawParams: {
|
||||
command: ["echo", "SAFE"],
|
||||
runId: "approval-1",
|
||||
approved: true,
|
||||
approvalDecision: "allow-once",
|
||||
},
|
||||
nodeId: "node-1",
|
||||
client,
|
||||
execApprovalManager: manager(makeRecord("echo SAFE", ["echo SAFE"])),
|
||||
nowMs: now,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
expect(result.message).toContain("approval id does not match request");
|
||||
expect(result.details?.code).toBe("APPROVAL_REQUEST_MISMATCH");
|
||||
});
|
||||
|
||||
test("accepts matching commandArgv binding for trailing-space argv", () => {
|
||||
const result = sanitizeSystemRunParamsForForwarding({
|
||||
rawParams: {
|
||||
command: ["runner "],
|
||||
runId: "approval-1",
|
||||
approved: true,
|
||||
approvalDecision: "allow-once",
|
||||
},
|
||||
nodeId: "node-1",
|
||||
client,
|
||||
execApprovalManager: manager(makeRecord('"runner "', ["runner "])),
|
||||
nowMs: now,
|
||||
});
|
||||
expectAllowOnceForwardingResult(result);
|
||||
});
|
||||
test("consumes allow-once approvals and blocks same runId replay", async () => {
|
||||
const approvalManager = new ExecApprovalManager();
|
||||
const runId = "approval-replay-1";
|
||||
|
||||
@@ -55,6 +55,7 @@ function clientHasApprovals(client: ApprovalClient | null): boolean {
|
||||
|
||||
function approvalMatchesRequest(
|
||||
cmdText: string,
|
||||
argv: string[],
|
||||
params: SystemRunParamsLike,
|
||||
record: ExecApprovalRecord,
|
||||
): boolean {
|
||||
@@ -62,7 +63,19 @@ function approvalMatchesRequest(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!cmdText || record.request.command !== cmdText) {
|
||||
const requestedArgv = Array.isArray(record.request.commandArgv)
|
||||
? record.request.commandArgv
|
||||
: null;
|
||||
if (requestedArgv) {
|
||||
if (requestedArgv.length === 0 || requestedArgv.length !== argv.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < requestedArgv.length; i += 1) {
|
||||
if (requestedArgv[i] !== argv[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else if (!cmdText || record.request.command !== cmdText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -237,7 +250,7 @@ export function sanitizeSystemRunParamsForForwarding(opts: {
|
||||
};
|
||||
}
|
||||
|
||||
if (!approvalMatchesRequest(cmdText, p, snapshot)) {
|
||||
if (!approvalMatchesRequest(cmdText, cmdTextResolution.argv, p, snapshot)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "approval id does not match request",
|
||||
|
||||
@@ -89,6 +89,7 @@ export const ExecApprovalRequestParamsSchema = Type.Object(
|
||||
{
|
||||
id: Type.Optional(NonEmptyString),
|
||||
command: NonEmptyString,
|
||||
commandArgv: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
|
||||
cwd: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
nodeId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
host: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
|
||||
@@ -43,6 +43,7 @@ export function createExecApprovalHandlers(
|
||||
const p = params as {
|
||||
id?: string;
|
||||
command: string;
|
||||
commandArgv?: string[] | null;
|
||||
cwd?: string;
|
||||
nodeId?: string;
|
||||
host?: string;
|
||||
@@ -60,6 +61,9 @@ export function createExecApprovalHandlers(
|
||||
const explicitId = typeof p.id === "string" && p.id.trim().length > 0 ? p.id.trim() : null;
|
||||
const host = typeof p.host === "string" ? p.host.trim() : "";
|
||||
const nodeId = typeof p.nodeId === "string" ? p.nodeId.trim() : "";
|
||||
const commandArgv = Array.isArray(p.commandArgv)
|
||||
? p.commandArgv.map((entry) => String(entry))
|
||||
: null;
|
||||
if (host === "node" && !nodeId) {
|
||||
respond(
|
||||
false,
|
||||
@@ -78,6 +82,7 @@ export function createExecApprovalHandlers(
|
||||
}
|
||||
const request = {
|
||||
command: p.command,
|
||||
commandArgv,
|
||||
cwd: p.cwd ?? null,
|
||||
nodeId: host === "node" ? nodeId : null,
|
||||
host: host || null,
|
||||
|
||||
Reference in New Issue
Block a user