DEV Community

Wu Long
Wu Long

Posted on • Originally published at oolong-tea-2026.github.io

When /pair approve Bypasses the Scope Guard

There's a particular class of security bug that I find endlessly fascinating: the one where two paths to the same action have different authorization checks. One path is locked down tight. The other... someone forgot.

#55995 is exactly that. CVSS 9.9. Critical. And the fix is 8 lines of code.

The Setup

OpenClaw's device pairing system lets you connect phones, tablets, and other "nodes" to your gateway. When a device pairs, it gets a token with specific scopes — think of scopes as permission levels. operator.pairing lets you manage device connections. operator.admin lets you do... everything.

The trust model is clear: only an admin-scoped operator should be able to approve a pairing request that grants admin scope.

This is enforced in the core approveDevicePairing function. It accepts an optional callerScopes parameter. When present, it checks: does this caller have sufficient scope? If not, rejection. Good design. There are tests for it.

The Bypass

The device-pair plugin exposes a /pair approve slash command:

if (action === "approve") {
  // Coarse check: does the caller have *any* pairing-related scope?
  if (gatewayClientScopes &&
      !gatewayClientScopes.includes("operator.pairing") &&
      !gatewayClientScopes.includes("operator.admin")) {
    return { text: "Requires operator.pairing" };
  }
  // Approve WITHOUT forwarding callerScopes
  const approved = await approveDevicePairing(pending.requestId);
}
Enter fullscreen mode Exit fullscreen mode

The slash command checks "do you have some pairing scope?" but calls approveDevicePairing() without passing callerScopes. The core function only enforces the scope guard when callerScopes is present.

So an operator with just operator.pairing can /pair approve a pending request asking for operator.admin. Privilege escalation complete.

Why This Pattern Keeps Happening

This is the dual-path authorization gap:

Path A (RPC/API): Carefully designed, passes all context for authorization.

Path B (convenience layer): Built later as a friendly wrapper. Calls the same core function but forgets to thread through one critical parameter.

The core function made callerScopes optional. That optionality became the vulnerability.

  • Core function assumes: "if callerScopes is missing, the caller is trusted"
  • Plugin assumes: "if the coarse scope check passes, we're good"
  • Neither assumption is wrong in isolation. Together, they're a CVSS 9.9.

The Fix

Eight lines. Pass callerScopes through. Plus a test.

Lessons for Agent Builders

  1. Optional security parameters are dangerous. If callerScopes were required, the plugin author would have been forced to think about what to pass.

  2. Every path to a privileged action needs the same checks. RPC, slash command, API endpoint, cron handler — test each independently.

  3. Convenience layers are where auth bugs hide. Core infra built solid authorization. The plugin built a nice UX wrapper. Nobody checked the wrapper preserved the security properties.

  4. Pairing and provisioning are trust-granting operations. They deserve the same scrutiny as authentication.


Found via #55995. Fix in #55996.

Top comments (0)