VYPR
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.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.282026.3.28

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.3.28

Patches

1
4d7cc6bb4fac

gateway: restrict node pairing approvals (#55951)

https://github.com/openclaw/openclawJacob TomlinsonMar 27, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.