From e8f13c625e3458e8d97fbc1441a23a8ed9e0a536 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 3 May 2026 01:37:41 +0100 Subject: [PATCH] fix(cli): request admin scope for admin device approvals --- CHANGELOG.md | 1 + docs/cli/devices.md | 4 +- docs/docs.json | 1 + docs/gateway/operator-scopes.md | 110 +++++++++++++++++++++++++++++++ docs/gateway/pairing.md | 2 + docs/gateway/protocol.md | 3 + docs/gateway/security/index.md | 2 + src/cli/devices-cli.test.ts | 76 ++++++++++++++++++++-- src/cli/devices-cli.ts | 111 +++++++++++++++++++++++++++++++- 9 files changed, 303 insertions(+), 7 deletions(-) create mode 100644 docs/gateway/operator-scopes.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cd997d0091..d12a3a31cec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Install/update: prune the obsolete `plugin-runtime-deps` state directory during packaged postinstall so upgrades from pre-2026.5.2 releases reclaim old bundled-plugin dependency caches without touching external plugin installs. - Auto-reply/queue: treat reset-triggered `/new` and `/reset` turns as interrupt runs across active-run queue handling, so steer/followup modes cannot delay a fresh session behind existing work. Fixes #74093. (#74144) Thanks @ruji9527 and @yelog. - Cron: preserve manual `cron.run` IDs in `cron.runs` history so manual run acknowledgements can be correlated with finished run records. Fixes #76276. +- CLI/devices: request `operator.admin` for `openclaw devices approve ` only when the exact pending device request would mint or inherit admin-scoped operator access, while keeping lower-scope approvals on the pairing scope. - Gateway: keep directly requested plugin tools invokable under restrictive tool profiles while preserving explicit deny lists and the HTTP safety deny list, preventing catalog/invoke mismatches that surface as "Tool not available". Thanks @BunsDev. - Gateway/update: allow beta binaries to refresh gateway services when the config was last written by the matching stable release version, avoiding false newer-config downgrade blocks during beta channel updates. - Channels: keep Matrix and Mattermost bundled in the core package instead of advertising external npm installs before those channels are cut over. Thanks @vincentkoc. diff --git a/docs/cli/devices.md b/docs/cli/devices.md index 31724f9b7bd..9942c80def2 100644 --- a/docs/cli/devices.md +++ b/docs/cli/devices.md @@ -137,7 +137,9 @@ When you set `--url`, the CLI does not fall back to config or environment creden ## Notes - Token rotation returns a new token (sensitive). Treat it like a secret. -- These commands require `operator.pairing` (or `operator.admin`) scope. +- These commands require `operator.pairing` (or `operator.admin`) scope. Some + approvals also require the caller to hold the operator scopes that the target + device would mint or inherit; see [Operator scopes](/gateway/operator-scopes). - `gateway.nodes.pairing.autoApproveCidrs` is an opt-in Gateway policy for fresh node device pairing only; it does not change CLI approval authority. - Token rotation and revocation stay inside the approved pairing role set and diff --git a/docs/docs.json b/docs/docs.json index 15397d3cc18..f70885136ed 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1497,6 +1497,7 @@ "pages": [ "gateway/security/index", "gateway/security/audit-checks", + "gateway/operator-scopes", "gateway/sandboxing", "gateway/openshell", "gateway/sandbox-vs-tool-policy-vs-elevated" diff --git a/docs/gateway/operator-scopes.md b/docs/gateway/operator-scopes.md new file mode 100644 index 00000000000..83c1a08f4a1 --- /dev/null +++ b/docs/gateway/operator-scopes.md @@ -0,0 +1,110 @@ +--- +summary: "Operator roles, scopes, and approval-time checks for Gateway clients" +read_when: + - Debugging missing operator scope errors + - Reviewing device or node pairing approvals + - Adding or classifying Gateway RPC methods +title: "Operator scopes" +--- + +Operator scopes define what a Gateway client may do after it authenticates. +They are a control-plane guardrail inside one trusted Gateway operator domain, +not hostile multi-tenant isolation. If you need strong separation between +people, teams, or machines, run separate Gateways under separate OS users or +hosts. + +Related: [Security](/gateway/security), [Gateway protocol](/gateway/protocol), +[Gateway pairing](/gateway/pairing), [Devices CLI](/cli/devices). + +## Roles + +Gateway WebSocket clients connect with one role: + +- `operator`: control-plane clients such as CLI, Control UI, automation, and + trusted helper processes. +- `node`: capability hosts such as macOS, iOS, Android, or headless nodes that + expose commands through `node.invoke`. + +Operator RPC methods require the `operator` role. Node-originated methods +require the `node` role. + +## Scope levels + +| Scope | Meaning | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `operator.read` | Read-only status, lists, catalog, logs, session reads, and other non-mutating control-plane calls. | +| `operator.write` | Normal mutating operator actions such as sending messages, invoking tools, updating talk/voice settings, and node command relay. Also satisfies `operator.read`. | +| `operator.admin` | Administrative control-plane access. Satisfies every `operator.*` scope. Required for config mutation, updates, native hooks, sensitive reserved namespaces, and high-risk approvals. | +| `operator.pairing` | Device and node pairing management, including listing, approving, rejecting, removing, rotating, and revoking pairing records or device tokens. | +| `operator.approvals` | Exec and plugin approval APIs. | +| `operator.talk.secrets` | Reading Talk configuration with secrets included. | + +Unknown future `operator.*` scopes require an exact match unless the caller has +`operator.admin`. + +## Method scope is only the first gate + +Each Gateway RPC has a least-privilege method scope. That method scope decides +whether the request can reach the handler. Some handlers then apply stricter +approval-time checks based on the concrete thing being approved or mutated. + +Examples: + +- `device.pair.approve` is reachable with `operator.pairing`, but approving an + operator device can only mint or preserve scopes the caller already holds. +- `node.pair.approve` is reachable with `operator.pairing`, then derives extra + approval scopes from the pending node command list. +- `chat.send` is normally a write-scoped method, but persistent `/config set` + and `/config unset` require `operator.admin` at command level. + +This lets lower-scope operators perform low-risk pairing actions without making +all pairing approval admin-only. + +## Device pairing approvals + +Device pairing records are the durable source of approved roles and scopes. +Already paired devices do not get broader access silently: reconnects that ask +for a broader role or broader scopes create a new pending upgrade request. + +When approving a device request: + +- A request with no operator role does not need operator token scope approval. +- A request for `operator.read`, `operator.write`, `operator.approvals`, + `operator.pairing`, or `operator.talk.secrets` requires the caller to hold + those scopes, or `operator.admin`. +- A request for `operator.admin` requires `operator.admin`. +- A repair request with no explicit scopes can inherit the existing operator + token scopes. If that existing token is admin-scoped, approval still requires + `operator.admin`. + +For paired-device token sessions, management is self-scoped unless the caller +also has `operator.admin`: non-admin callers can rotate, revoke, or remove only +their own device entry. + +## Node pairing approvals + +Legacy `node.pair.*` uses a separate Gateway-owned node pairing store. WS nodes +use device pairing with `role: node`, but the same approval-level vocabulary +applies. + +`node.pair.approve` uses the pending request command list to derive additional +required scopes: + +- Commandless request: `operator.pairing` +- Non-exec node commands: `operator.pairing` + `operator.write` +- `system.run`, `system.run.prepare`, or `system.which`: + `operator.pairing` + `operator.admin` + +Node pairing establishes identity and trust. It does not replace the node's +own `system.run` exec approval policy. + +## Shared-secret auth + +Shared gateway token/password auth is treated as trusted operator access for +that Gateway. OpenAI-compatible HTTP surfaces and `/tools/invoke` restore the +normal full operator default scope set for shared-secret bearer auth, even if a +caller sends narrower declared scopes. + +Identity-bearing modes, such as trusted proxy auth or private-ingress `none`, +can still honor explicit declared scopes. Use separate Gateways for real trust +boundary separation. diff --git a/docs/gateway/pairing.md b/docs/gateway/pairing.md index 86d84bc2b86..89bec89181d 100644 --- a/docs/gateway/pairing.md +++ b/docs/gateway/pairing.md @@ -69,6 +69,8 @@ Notes: metadata and the latest allowlisted declared command snapshot for operator visibility. - Approval **always** generates a fresh token; no token is ever returned from `node.pair.request`. +- Operator scope levels and approval-time checks are summarized in + [Operator scopes](/gateway/operator-scopes). - Requests may include `silent: true` as a hint for auto-approval flows. - `node.pair.approve` uses the pending request's declared commands to enforce extra approval scopes: diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 0b467135fed..b15ee802bf5 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -211,6 +211,9 @@ Side-effecting methods require **idempotency keys** (see schema). ## Roles + scopes +For the full operator scope model, approval-time checks, and shared-secret +semantics, see [Operator scopes](/gateway/operator-scopes). + ### Roles - `operator` = control plane client (CLI/UI/automation). diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 482818a9373..d46d32ee44e 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -92,6 +92,8 @@ Treat Gateway and node as one operator trust domain, with different roles: - **Gateway** is the control plane and policy surface (`gateway.auth`, tool policy, routing). - **Node** is remote execution surface paired to that Gateway (commands, device actions, host-local capabilities). - A caller authenticated to the Gateway is trusted at Gateway scope. After pairing, node actions are trusted operator actions on that node. +- Operator scope levels and approval-time checks are summarized in + [Operator scopes](/gateway/operator-scopes). - Direct loopback backend clients authenticated with the shared gateway token/password can make internal control-plane RPCs without presenting a user device identity. This is not a remote or browser pairing bypass: network diff --git a/src/cli/devices-cli.test.ts b/src/cli/devices-cli.test.ts index 46aba5f8618..5069526ff34 100644 --- a/src/cli/devices-cli.test.ts +++ b/src/cli/devices-cli.test.ts @@ -119,16 +119,83 @@ function mockLocalPairingFallback(message?: string) { } describe("devices cli approve", () => { - it("approves an explicit request id without listing", async () => { - callGateway.mockResolvedValueOnce({ device: { deviceId: "device-1" } }); + it("uses admin scope when approving an admin-scope request", async () => { + callGateway + .mockResolvedValueOnce({ + pending: [pendingDevice({ requestId: "req-123", scopes: ["operator.admin"] })], + paired: [], + }) + .mockResolvedValueOnce({ device: { deviceId: "device-1" } }); await runDevicesApprove(["req-123"]); - expect(callGateway).toHaveBeenCalledTimes(1); - expect(callGateway).toHaveBeenCalledWith( + expect(callGateway).toHaveBeenCalledTimes(2); + expect(callGateway).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + method: "device.pair.list", + }), + ); + expect(callGateway).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ method: "device.pair.approve", params: { requestId: "req-123" }, + scopes: ["operator.admin"], + }), + ); + }); + + it("keeps pairing scope for non-admin device approvals", async () => { + callGateway + .mockResolvedValueOnce({ + pending: [ + pendingDevice({ + requestId: "req-pairing", + scopes: ["operator.pairing"], + }), + ], + paired: [], + }) + .mockResolvedValueOnce({ device: { deviceId: "device-1" } }); + + await runDevicesApprove(["req-pairing"]); + + expect(callGateway).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: "device.pair.approve", + params: { requestId: "req-pairing" }, + scopes: ["operator.pairing"], + }), + ); + }); + + it("uses admin scope when a repair approval would inherit an admin token", async () => { + callGateway + .mockResolvedValueOnce({ + pending: [ + pendingDevice({ + requestId: "req-repair", + scopes: [], + }), + ], + paired: [ + pairedDevice({ + tokens: [{ role: "operator", scopes: ["operator.admin"] }], + }), + ], + }) + .mockResolvedValueOnce({ device: { deviceId: "device-1" } }); + + await runDevicesApprove(["req-repair"]); + + expect(callGateway).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: "device.pair.approve", + params: { requestId: "req-repair" }, + scopes: ["operator.admin"], }), ); }); @@ -462,6 +529,7 @@ describe("devices cli local fallback", () => { }); it("falls back to local approve when gateway returns pairing required on loopback", async () => { + mockLocalPairingFallback(); rejectGatewayForLocalFallback(); approveDevicePairing.mockResolvedValueOnce({ requestId: "req-latest", diff --git a/src/cli/devices-cli.ts b/src/cli/devices-cli.ts index d94dd0532d8..1ca700f2fb4 100644 --- a/src/cli/devices-cli.ts +++ b/src/cli/devices-cli.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; +import { ADMIN_SCOPE, PAIRING_SCOPE, type OperatorScope } from "../gateway/method-scopes.js"; import { isLoopbackHost } from "../gateway/net.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../gateway/protocol/client-info.js"; import { readConnectPairingRequiredMessage } from "../gateway/protocol/connect-error-details.js"; @@ -12,6 +13,7 @@ import { } from "../infra/device-pairing.js"; import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; import { defaultRuntime } from "../runtime.js"; +import { normalizeDeviceAuthScopes } from "../shared/device-auth.js"; import { resolvePendingDeviceApprovalState, type DevicePairingAccessSummary, @@ -80,6 +82,15 @@ type DevicePairingList = { const FALLBACK_NOTICE = "Direct scope access failed; using local fallback."; const DEFAULT_DEVICES_TIMEOUT_MS = 10_000; +const OPERATOR_ROLE = "operator"; +const OPERATOR_SCOPE_PREFIX = "operator."; +const KNOWN_NON_ADMIN_OPERATOR_SCOPES = new Set([ + "operator.approvals", + "operator.pairing", + "operator.read", + "operator.talk.secrets", + "operator.write", +]); const devicesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) => cmd @@ -93,7 +104,12 @@ const devicesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) => ) .option("--json", "Output JSON", false); -const callGatewayCli = async (method: string, opts: DevicesRpcOpts, params?: unknown) => +const callGatewayCli = async ( + method: string, + opts: DevicesRpcOpts, + params?: unknown, + callOpts?: { scopes?: OperatorScope[] }, +) => withProgress( { label: `Devices ${method}`, @@ -110,6 +126,7 @@ const callGatewayCli = async (method: string, opts: DevicesRpcOpts, params?: unk timeoutMs: Number(opts.timeout ?? DEFAULT_DEVICES_TIMEOUT_MS), clientName: GATEWAY_CLIENT_NAMES.CLI, mode: GATEWAY_CLIENT_MODES.CLI, + scopes: callOpts?.scopes, }), ); @@ -171,8 +188,14 @@ async function approvePairingWithFallback( opts: DevicesRpcOpts, requestId: string, ): Promise | null> { + const scopes = await resolveApprovePairingGatewayScopes(opts, requestId); try { - return await callGatewayCli("device.pair.approve", opts, { requestId }); + return await callGatewayCli( + "device.pair.approve", + opts, + { requestId }, + scopes ? { scopes } : undefined, + ); } catch (error) { if (!shouldUseLocalPairingFallback(opts, error)) { throw error; @@ -206,6 +229,90 @@ function parseDevicePairingList(value: unknown): DevicePairingList { }; } +function normalizeDeviceRoles(request: PendingDevice): string[] { + const roles = new Set(); + for (const role of request.roles ?? []) { + const normalized = normalizeOptionalString(role); + if (normalized) { + roles.add(normalized); + } + } + const role = normalizeOptionalString(request.role); + if (role) { + roles.add(role); + } + return [...roles]; +} + +function normalizeOperatorScopes(scopes: string[] | undefined): string[] { + return normalizeDeviceAuthScopes(scopes).filter((scope) => + scope.startsWith(OPERATOR_SCOPE_PREFIX), + ); +} + +function resolvePairedOperatorScopes(paired: PairedDevice | undefined): string[] { + const operatorToken = paired?.tokens?.find((token) => { + const role = normalizeOptionalString(token.role); + return role === OPERATOR_ROLE && !token.revokedAtMs; + }); + return normalizeOperatorScopes(operatorToken?.scopes ?? paired?.scopes); +} + +function resolvePendingOperatorApprovalScopes( + request: PendingDevice, + paired: PairedDevice | undefined, +): string[] { + if (!normalizeDeviceRoles(request).includes(OPERATOR_ROLE)) { + return []; + } + const requestedScopes = normalizeOperatorScopes(request.scopes); + return requestedScopes.length > 0 ? requestedScopes : resolvePairedOperatorScopes(paired); +} + +function isKnownNonAdminOperatorScope(scope: string): scope is OperatorScope { + return KNOWN_NON_ADMIN_OPERATOR_SCOPES.has(scope as OperatorScope); +} + +function resolveApprovePairingScopesForRequest( + request: PendingDevice, + paired: PairedDevice | undefined, +): OperatorScope[] | undefined { + const operatorScopes = resolvePendingOperatorApprovalScopes(request, paired); + if (operatorScopes.length === 0) { + return undefined; + } + if (operatorScopes.includes(ADMIN_SCOPE)) { + return [ADMIN_SCOPE]; + } + const out = new Set([PAIRING_SCOPE]); + for (const scope of operatorScopes) { + if (!isKnownNonAdminOperatorScope(scope)) { + return [ADMIN_SCOPE]; + } + out.add(scope); + } + return [...out]; +} + +async function resolveApprovePairingGatewayScopes( + opts: DevicesRpcOpts, + requestId: string, +): Promise { + try { + const list = await listPairingWithFallback(opts); + const request = list.pending?.find((pending) => pending.requestId === requestId); + if (!request) { + return undefined; + } + return resolveApprovePairingScopesForRequest( + request, + lookupPairedDevice(indexPairedDevices(list.paired), request), + ); + } catch { + return undefined; + } +} + function selectLatestPendingRequest(pending: PendingDevice[] | undefined) { if (!pending?.length) { return null;