High severity8.1NVD Advisory· Published Mar 31, 2026· Updated Apr 1, 2026
CVE-2026-33577
CVE-2026-33577
Description
OpenClaw before 2026.3.28 contains an insufficient scope validation vulnerability in the node pairing approval path that allows low-privilege operators to approve nodes with broader scopes. Attackers can exploit missing callerScopes validation in node-pairing.ts to extend privileges onto paired nodes beyond their authorization level.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.3.28 | 2026.3.28 |
Affected products
1Patches
14d7cc6bb4facgateway: restrict node pairing approvals (#55951)
11 files changed · +581 −13
src/agents/tools/gateway.test.ts+16 −0 modified@@ -174,6 +174,22 @@ describe("gateway tool defaults", () => { ); }); + it("allows explicit scope overrides for dynamic callers", async () => { + callGatewayMock.mockResolvedValueOnce({ ok: true }); + await callGatewayTool( + "node.pair.approve", + {}, + { requestId: "req-1" }, + { scopes: ["operator.admin"] }, + ); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "node.pair.approve", + scopes: ["operator.admin"], + }), + ); + }); + it("default-denies unknown methods by sending no scopes", async () => { callGatewayMock.mockResolvedValueOnce({ ok: true }); await callGatewayTool("nonexistent.method", {}, {});
src/agents/tools/gateway.ts+8 −3 modified@@ -1,7 +1,10 @@ import { loadConfig, resolveGatewayPort } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; import { resolveGatewayCredentialsFromConfig, trimToUndefined } from "../../gateway/credentials.js"; -import { resolveLeastPrivilegeOperatorScopesForMethod } from "../../gateway/method-scopes.js"; +import { + resolveLeastPrivilegeOperatorScopesForMethod, + type OperatorScope, +} from "../../gateway/method-scopes.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import { readStringParam } from "./common.js"; @@ -141,10 +144,12 @@ export async function callGatewayTool<T = Record<string, unknown>>( method: string, opts: GatewayCallOptions, params?: unknown, - extra?: { expectFinal?: boolean }, + extra?: { expectFinal?: boolean; scopes?: OperatorScope[] }, ) { const gateway = resolveGatewayOptions(opts); - const scopes = resolveLeastPrivilegeOperatorScopesForMethod(method); + const scopes = Array.isArray(extra?.scopes) + ? extra.scopes + : resolveLeastPrivilegeOperatorScopesForMethod(method); return await callGateway<T>({ url: gateway.url, token: gateway.token,
src/agents/tools/nodes-tool.test.ts+112 −0 modified@@ -273,4 +273,116 @@ describe("createNodesTool screen_record duration guardrails", () => { }); expect(JSON.stringify(result?.content ?? [])).not.toContain("MEDIA:"); }); + + it("uses operator.admin to approve exec-capable node pair requests", async () => { + gatewayMocks.callGatewayTool.mockImplementation(async (method, _opts, params, extra) => { + if (method === "node.pair.list") { + return { + pending: [ + { + requestId: "req-1", + commands: ["system.run"], + }, + ], + }; + } + if (method === "node.pair.approve") { + return { ok: true, method, params, extra }; + } + throw new Error(`unexpected method: ${String(method)}`); + }); + const tool = createNodesTool(); + + await tool.execute("call-1", { + action: "approve", + requestId: "req-1", + }); + + expect(gatewayMocks.callGatewayTool).toHaveBeenNthCalledWith( + 1, + "node.pair.list", + {}, + {}, + { scopes: ["operator.pairing", "operator.write"] }, + ); + expect(gatewayMocks.callGatewayTool).toHaveBeenNthCalledWith( + 2, + "node.pair.approve", + {}, + { requestId: "req-1" }, + { scopes: ["operator.admin"] }, + ); + }); + + it("uses operator.write to approve non-exec node pair requests", async () => { + gatewayMocks.callGatewayTool.mockImplementation(async (method, _opts, params, extra) => { + if (method === "node.pair.list") { + return { + pending: [ + { + requestId: "req-1", + commands: ["canvas.snapshot"], + }, + ], + }; + } + if (method === "node.pair.approve") { + return { ok: true, method, params, extra }; + } + throw new Error(`unexpected method: ${String(method)}`); + }); + const tool = createNodesTool(); + + await tool.execute("call-1", { + action: "approve", + requestId: "req-1", + }); + + expect(gatewayMocks.callGatewayTool).toHaveBeenNthCalledWith( + 1, + "node.pair.list", + {}, + {}, + { scopes: ["operator.pairing", "operator.write"] }, + ); + expect(gatewayMocks.callGatewayTool).toHaveBeenNthCalledWith( + 2, + "node.pair.approve", + {}, + { requestId: "req-1" }, + { scopes: ["operator.write"] }, + ); + }); + + it("uses operator.write for commandless node pair requests", async () => { + gatewayMocks.callGatewayTool.mockImplementation(async (method, _opts, params, extra) => { + if (method === "node.pair.list") { + return { + pending: [ + { + requestId: "req-1", + }, + ], + }; + } + if (method === "node.pair.approve") { + return { ok: true, method, params, extra }; + } + throw new Error(`unexpected method: ${String(method)}`); + }); + const tool = createNodesTool(); + + await tool.execute("call-1", { + action: "approve", + requestId: "req-1", + }); + + expect(gatewayMocks.callGatewayTool).toHaveBeenNthCalledWith( + 2, + "node.pair.approve", + {}, + { requestId: "req-1" }, + { scopes: ["operator.write"] }, + ); + }); });
src/agents/tools/nodes-tool.ts+38 −3 modified@@ -17,6 +17,8 @@ import { } from "../../cli/nodes-screen.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; import type { OpenClawConfig } from "../../config/config.js"; +import type { OperatorScope } from "../../gateway/method-scopes.js"; +import { NODE_SYSTEM_RUN_COMMANDS } from "../../infra/node-commands.js"; import { parsePreparedSystemRunPayload } from "../../infra/system-run-approval-context.js"; import { imageMimeFromFormat } from "../../media/mime.js"; import type { GatewayMessageChannel } from "../../utils/message-channel.js"; @@ -72,6 +74,33 @@ const NODE_READ_ACTION_COMMANDS = { } as const; type GatewayCallOptions = ReturnType<typeof readGatewayCallOptions>; +function resolveApproveScopes(commands: unknown): OperatorScope[] { + const normalized = Array.isArray(commands) + ? commands.filter((value): value is string => typeof value === "string") + : []; + if ( + normalized.some((command) => NODE_SYSTEM_RUN_COMMANDS.some((allowed) => allowed === command)) + ) { + return ["operator.admin"]; + } + if (normalized.length > 0) { + return ["operator.write"]; + } + return ["operator.write"]; +} + +async function resolveNodePairApproveScopes( + gatewayOpts: GatewayCallOptions, + requestId: string, +): Promise<OperatorScope[]> { + const pairing = await callGatewayTool<{ + pending?: Array<{ requestId?: string; commands?: unknown }>; + }>("node.pair.list", gatewayOpts, {}, { scopes: ["operator.pairing", "operator.write"] }); + const pending = Array.isArray(pairing?.pending) ? pairing.pending : []; + const match = pending.find((entry) => entry?.requestId === requestId); + return resolveApproveScopes(match?.commands); +} + async function invokeNodeCommandPayload(params: { gatewayOpts: GatewayCallOptions; node: string; @@ -199,10 +228,16 @@ export function createNodesTool(options?: { const requestId = readStringParam(params, "requestId", { required: true, }); + const scopes = await resolveNodePairApproveScopes(gatewayOpts, requestId); return jsonResult( - await callGatewayTool("node.pair.approve", gatewayOpts, { - requestId, - }), + await callGatewayTool( + "node.pair.approve", + gatewayOpts, + { + requestId, + }, + { scopes }, + ), ); } case "reject": {
src/gateway/method-scopes.test.ts+5 −0 modified@@ -22,6 +22,7 @@ describe("method scope resolution", () => { ["sessions.abort", ["operator.write"]], ["sessions.messages.subscribe", ["operator.read"]], ["sessions.messages.unsubscribe", ["operator.read"]], + ["node.pair.approve", ["operator.write"]], ["poll", ["operator.write"]], ["config.patch", ["operator.admin"]], ["wizard.start", ["operator.admin"]], @@ -66,6 +67,10 @@ describe("operator scope authorization", () => { allowed: false, missingScope: "operator.write", }); + expect(authorizeOperatorScopesForMethod("node.pair.approve", ["operator.pairing"])).toEqual({ + allowed: false, + missingScope: "operator.write", + }); }); it("requires approvals scope for approval methods", () => {
src/gateway/method-scopes.ts+1 −1 modified@@ -43,7 +43,6 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = { [PAIRING_SCOPE]: [ "node.pair.request", "node.pair.list", - "node.pair.approve", "node.pair.reject", "node.pair.verify", "device.pair.list", @@ -111,6 +110,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = { "tts.setProvider", "voicewake.set", "node.invoke", + "node.pair.approve", "chat.send", "chat.abort", "sessions.create",
src/gateway/server-methods/nodes.ts+18 −3 modified@@ -539,7 +539,7 @@ export const nodeHandlers: GatewayRequestHandlers = { respond(true, list, undefined); }); }, - "node.pair.approve": async ({ params, respond, context }) => { + "node.pair.approve": async ({ params, respond, context, client }) => { if (!validateNodePairApproveParams(params)) { respondInvalidParams({ respond, @@ -549,17 +549,32 @@ export const nodeHandlers: GatewayRequestHandlers = { return; } const { requestId } = params as { requestId: string }; + // Intentionally fail closed for RPC callers without an explicit scoped session. + const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; await respondUnavailableOnThrow(respond, async () => { - const approved = await approveNodePairing(requestId); + const approved = await approveNodePairing(requestId, { callerScopes }); if (!approved) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId")); return; } + if ("status" in approved && approved.status === "forbidden") { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${approved.missingScope}`), + ); + return; + } + if (!("node" in approved)) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId")); + return; + } + const approvedNode = approved.node; context.broadcast( "node.pair.resolved", { requestId, - nodeId: approved.node.nodeId, + nodeId: approvedNode.nodeId, decision: "approved", ts: Date.now(), },
src/gateway/server.node-pairing-authz.test.ts+270 −0 added@@ -0,0 +1,270 @@ +import { describe, expect, test } from "vitest"; +import { WebSocket } from "ws"; +import { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js"; +import { approveNodePairing, getPairedNode, requestNodePairing } from "../infra/node-pairing.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { + issueOperatorToken, + loadDeviceIdentity, + openTrackedWs, +} from "./device-authz.test-helpers.js"; +import { connectGatewayClient } from "./test-helpers.e2e.js"; +import { + connectOk, + installGatewayTestHooks, + rpcReq, + startServerWithClient, +} from "./test-helpers.js"; + +installGatewayTestHooks({ scope: "suite" }); + +async function connectNodeClientWithPairing(params: { + port: number; + deviceIdentity: ReturnType<typeof loadDeviceIdentity>["identity"]; + commands: string[]; +}) { + const connect = async () => + await connectGatewayClient({ + url: `ws://127.0.0.1:${params.port}`, + token: "secret", + role: "node", + clientName: GATEWAY_CLIENT_NAMES.NODE_HOST, + clientDisplayName: "node-command-pin", + clientVersion: "1.0.0", + platform: "darwin", + mode: GATEWAY_CLIENT_MODES.NODE, + commands: params.commands, + deviceIdentity: params.deviceIdentity, + timeoutMessage: "timeout waiting for paired node to connect", + }); + + try { + return await connect(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("pairing required")) { + throw error; + } + const pairing = await listDevicePairing(); + for (const pending of pairing.pending) { + await approveDevicePairing(pending.requestId); + } + return await connect(); + } +} + +describe("gateway node pairing authorization", () => { + test("requires operator.write before node pairing approvals", async () => { + const started = await startServerWithClient("secret"); + const approver = await issueOperatorToken({ + name: "node-pair-approve-pairing-only", + approvedScopes: ["operator.admin"], + tokenScopes: ["operator.pairing"], + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + }); + + let pairingWs: WebSocket | undefined; + try { + const request = await requestNodePairing({ + nodeId: "node-approve-target", + platform: "darwin", + commands: ["system.run"], + }); + + pairingWs = await openTrackedWs(started.port); + await connectOk(pairingWs, { + skipDefaultAuth: true, + deviceToken: approver.token, + deviceIdentityPath: approver.identityPath, + scopes: ["operator.pairing"], + }); + + const approve = await rpcReq(pairingWs, "node.pair.approve", { + requestId: request.request.requestId, + }); + expect(approve.ok).toBe(false); + expect(approve.error?.message).toBe("missing scope: operator.write"); + + await expect(getPairedNode("node-approve-target")).resolves.toBeNull(); + } finally { + pairingWs?.close(); + started.ws.close(); + await started.server.close(); + started.envSnapshot.restore(); + } + }); + + test("rejects approving exec-capable node commands above the caller session scopes", async () => { + const started = await startServerWithClient("secret"); + const approver = await issueOperatorToken({ + name: "node-pair-approve-attacker", + approvedScopes: ["operator.admin"], + tokenScopes: ["operator.write"], + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + }); + + let pairingWs: WebSocket | undefined; + try { + const request = await requestNodePairing({ + nodeId: "node-approve-target", + platform: "darwin", + commands: ["system.run"], + }); + + pairingWs = await openTrackedWs(started.port); + await connectOk(pairingWs, { + skipDefaultAuth: true, + deviceToken: approver.token, + deviceIdentityPath: approver.identityPath, + scopes: ["operator.write"], + }); + + const approve = await rpcReq(pairingWs, "node.pair.approve", { + requestId: request.request.requestId, + }); + expect(approve.ok).toBe(false); + expect(approve.error?.message).toBe("missing scope: operator.admin"); + + await expect(getPairedNode("node-approve-target")).resolves.toBeNull(); + } finally { + pairingWs?.close(); + started.ws.close(); + await started.server.close(); + started.envSnapshot.restore(); + } + }); + + test("pins connected node commands to the approved pairing record", async () => { + const started = await startServerWithClient("secret"); + const pairedNode = loadDeviceIdentity("node-command-pin"); + + let controlWs: WebSocket | undefined; + let firstClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined; + let nodeClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined; + try { + controlWs = await openTrackedWs(started.port); + await connectOk(controlWs, { token: "secret" }); + + firstClient = await connectNodeClientWithPairing({ + port: started.port, + deviceIdentity: pairedNode.identity, + commands: ["canvas.snapshot"], + }); + await firstClient.stopAndWait(); + + const request = await requestNodePairing({ + nodeId: pairedNode.identity.deviceId, + platform: "darwin", + commands: ["canvas.snapshot"], + }); + await approveNodePairing(request.request.requestId); + + nodeClient = await connectNodeClientWithPairing({ + port: started.port, + deviceIdentity: pairedNode.identity, + commands: ["canvas.snapshot", "system.run"], + }); + + const deadline = Date.now() + 2_000; + let lastNodes: Array<{ nodeId: string; connected?: boolean; commands?: string[] }> = []; + while (Date.now() < deadline) { + const list = await rpcReq<{ + nodes?: Array<{ nodeId: string; connected?: boolean; commands?: string[] }>; + }>(controlWs, "node.list", {}); + lastNodes = list.payload?.nodes ?? []; + const node = lastNodes.find( + (entry) => entry.nodeId === pairedNode.identity.deviceId && entry.connected, + ); + if ( + JSON.stringify(node?.commands?.toSorted() ?? []) === JSON.stringify(["canvas.snapshot"]) + ) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + const connectedNode = lastNodes.find( + (entry) => entry.nodeId === pairedNode.identity.deviceId && entry.connected, + ); + expect(connectedNode?.commands?.toSorted(), JSON.stringify(lastNodes)).toEqual([ + "canvas.snapshot", + ]); + + const invoke = await rpcReq(controlWs, "node.invoke", { + nodeId: pairedNode.identity.deviceId, + command: "system.run", + params: { command: "echo blocked" }, + idempotencyKey: "node-command-pin", + }); + expect(invoke.ok).toBe(false); + expect(invoke.error?.message ?? "").toContain("node command not allowed"); + } finally { + controlWs?.close(); + await firstClient?.stopAndWait(); + await nodeClient?.stopAndWait(); + started.ws.close(); + await started.server.close(); + started.envSnapshot.restore(); + } + }); + + test("treats paired nodes without stored commands as having no approved commands", async () => { + const started = await startServerWithClient("secret"); + const pairedNode = loadDeviceIdentity("node-command-empty"); + + let controlWs: WebSocket | undefined; + let firstClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined; + let nodeClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined; + try { + controlWs = await openTrackedWs(started.port); + await connectOk(controlWs, { token: "secret" }); + + firstClient = await connectNodeClientWithPairing({ + port: started.port, + deviceIdentity: pairedNode.identity, + commands: ["canvas.snapshot"], + }); + await firstClient.stopAndWait(); + + const request = await requestNodePairing({ + nodeId: pairedNode.identity.deviceId, + platform: "darwin", + }); + await approveNodePairing(request.request.requestId); + + nodeClient = await connectNodeClientWithPairing({ + port: started.port, + deviceIdentity: pairedNode.identity, + commands: ["canvas.snapshot", "system.run"], + }); + + const deadline = Date.now() + 2_000; + let lastNodes: Array<{ nodeId: string; connected?: boolean; commands?: string[] }> = []; + while (Date.now() < deadline) { + const list = await rpcReq<{ + nodes?: Array<{ nodeId: string; connected?: boolean; commands?: string[] }>; + }>(controlWs, "node.list", {}); + lastNodes = list.payload?.nodes ?? []; + const node = lastNodes.find( + (entry) => entry.nodeId === pairedNode.identity.deviceId && entry.connected, + ); + if ((node?.commands?.length ?? 0) === 0) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + const connectedNode = lastNodes.find( + (entry) => entry.nodeId === pairedNode.identity.deviceId && entry.connected, + ); + expect(connectedNode?.commands ?? [], JSON.stringify(lastNodes)).toEqual([]); + } finally { + controlWs?.close(); + await firstClient?.stopAndWait(); + await nodeClient?.stopAndWait(); + started.ws.close(); + await started.server.close(); + started.envSnapshot.restore(); + } + }); +});
src/gateway/server/ws-connection/message-handler.ts+10 −2 modified@@ -16,7 +16,7 @@ import { updatePairedDeviceMetadata, verifyDeviceToken, } from "../../../infra/device-pairing.js"; -import { updatePairedNodeMetadata } from "../../../infra/node-pairing.js"; +import { getPairedNode, updatePairedNodeMetadata } from "../../../infra/node-pairing.js"; import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../infra/skills-remote.js"; import { upsertPresence } from "../../../infra/system-presence.js"; import { loadVoiceWakeConfig } from "../../../infra/voicewake.js"; @@ -966,14 +966,22 @@ export function attachGatewayWsMessageHandler(params: { if (role === "node") { const cfg = loadConfig(); + const nodeId = connectParams.device?.id ?? connectParams.client.id; + const pairedNode = await getPairedNode(nodeId); const allowlist = resolveNodeCommandAllowlist(cfg, { platform: connectParams.client.platform, deviceFamily: connectParams.client.deviceFamily, }); const declared = Array.isArray(connectParams.commands) ? connectParams.commands : []; + const pairedCommands = pairedNode ? new Set(pairedNode.commands ?? []) : null; const filtered = declared .map((cmd) => cmd.trim()) - .filter((cmd) => cmd.length > 0 && allowlist.has(cmd)); + .filter( + (cmd) => + cmd.length > 0 && + allowlist.has(cmd) && + (pairedCommands === null || pairedCommands.has(cmd)), + ); connectParams.commands = filtered; }
src/infra/node-pairing.test.ts+56 −0 modified@@ -79,4 +79,60 @@ describe("node pairing tokens", () => { ok: false, }); }); + + test("requires operator.admin to approve system.run node commands", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-node-pairing-")); + const request = await requestNodePairing( + { + nodeId: "node-1", + platform: "darwin", + commands: ["system.run"], + }, + baseDir, + ); + + await expect( + approveNodePairing( + request.request.requestId, + { callerScopes: ["operator.pairing"] }, + baseDir, + ), + ).resolves.toEqual({ + status: "forbidden", + missingScope: "operator.admin", + }); + await expect(getPairedNode("node-1", baseDir)).resolves.toBeNull(); + }); + + test("requires operator.write to approve non-exec node commands", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-node-pairing-")); + const request = await requestNodePairing( + { + nodeId: "node-1", + platform: "darwin", + commands: ["canvas.present"], + }, + baseDir, + ); + + await expect( + approveNodePairing( + request.request.requestId, + { callerScopes: ["operator.pairing"] }, + baseDir, + ), + ).resolves.toEqual({ + status: "forbidden", + missingScope: "operator.write", + }); + await expect( + approveNodePairing(request.request.requestId, { callerScopes: ["operator.write"] }, baseDir), + ).resolves.toEqual({ + requestId: request.request.requestId, + node: expect.objectContaining({ + nodeId: "node-1", + commands: ["canvas.present"], + }), + }); + }); });
src/infra/node-pairing.ts+47 −1 modified@@ -1,4 +1,6 @@ import { randomUUID } from "node:crypto"; +import { resolveMissingRequestedScope } from "../shared/operator-scope-compat.js"; +import { NODE_SYSTEM_RUN_COMMANDS } from "./node-commands.js"; import { createAsyncLock, pruneExpiredPending, @@ -51,9 +53,27 @@ type NodePairingStateFile = { }; const PENDING_TTL_MS = 5 * 60 * 1000; +const OPERATOR_ROLE = "operator"; +const OPERATOR_WRITE_SCOPE = "operator.write"; +const OPERATOR_ADMIN_SCOPE = "operator.admin"; const withLock = createAsyncLock(); +function resolveNodeApprovalRequiredScope(pending: NodePairingPendingRequest): string | null { + const commands = Array.isArray(pending.commands) ? pending.commands : []; + if (commands.some((command) => NODE_SYSTEM_RUN_COMMANDS.some((allowed) => allowed === command))) { + return OPERATOR_ADMIN_SCOPE; + } + if (commands.length > 0) { + return OPERATOR_WRITE_SCOPE; + } + return null; +} + +type ApprovedNodePairingResult = { requestId: string; node: NodePairingPairedNode }; +type ForbiddenNodePairingResult = { status: "forbidden"; missingScope: string }; +type ApproveNodePairingResult = ApprovedNodePairingResult | ForbiddenNodePairingResult | null; + async function loadState(baseDir?: string): Promise<NodePairingStateFile> { const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "nodes"); const [pending, paired] = await Promise.all([ @@ -146,13 +166,39 @@ export async function requestNodePairing( export async function approveNodePairing( requestId: string, baseDir?: string, -): Promise<{ requestId: string; node: NodePairingPairedNode } | null> { +): Promise<ApprovedNodePairingResult | null>; +export async function approveNodePairing( + requestId: string, + options: { callerScopes?: readonly string[] }, + baseDir?: string, +): Promise<ApproveNodePairingResult>; +export async function approveNodePairing( + requestId: string, + optionsOrBaseDir?: { callerScopes?: readonly string[] } | string, + maybeBaseDir?: string, +): Promise<ApproveNodePairingResult> { + const options = + typeof optionsOrBaseDir === "string" || optionsOrBaseDir === undefined + ? undefined + : optionsOrBaseDir; + const baseDir = typeof optionsOrBaseDir === "string" ? optionsOrBaseDir : maybeBaseDir; return await withLock(async () => { const state = await loadState(baseDir); const pending = state.pendingById[requestId]; if (!pending) { return null; } + const requiredScope = resolveNodeApprovalRequiredScope(pending); + if (requiredScope && options !== undefined) { + const missingScope = resolveMissingRequestedScope({ + role: OPERATOR_ROLE, + requestedScopes: [requiredScope], + allowedScopes: options.callerScopes ?? [], + }); + if (missingScope) { + return { status: "forbidden", missingScope }; + } + } const now = Date.now(); const existing = state.pairedByNodeId[pending.nodeId];
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/openclaw/openclaw/commit/4d7cc6bb4fac68b5a5fadd1c5a23168281221f34nvdPatchWEB
- github.com/advisories/GHSA-2x4x-cc5g-qmmgghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-2x4x-cc5g-qmmgnvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-33577ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-insufficient-scope-validation-in-node-pair-approvenvdThird Party AdvisoryWEB
News mentions
0No linked articles in our index yet.