Skip to content

Commit e8f13c6

Browse files
committed
fix(cli): request admin scope for admin device approvals
1 parent e1a73d3 commit e8f13c6

9 files changed

Lines changed: 303 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
2121
- 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.
2222
- 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.
2323
- Cron: preserve manual `cron.run` IDs in `cron.runs` history so manual run acknowledgements can be correlated with finished run records. Fixes #76276.
24+
- CLI/devices: request `operator.admin` for `openclaw devices approve <requestId>` only when the exact pending device request would mint or inherit admin-scoped operator access, while keeping lower-scope approvals on the pairing scope.
2425
- 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.
2526
- 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.
2627
- Channels: keep Matrix and Mattermost bundled in the core package instead of advertising external npm installs before those channels are cut over. Thanks @vincentkoc.

docs/cli/devices.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,9 @@ When you set `--url`, the CLI does not fall back to config or environment creden
137137
## Notes
138138

139139
- Token rotation returns a new token (sensitive). Treat it like a secret.
140-
- These commands require `operator.pairing` (or `operator.admin`) scope.
140+
- These commands require `operator.pairing` (or `operator.admin`) scope. Some
141+
approvals also require the caller to hold the operator scopes that the target
142+
device would mint or inherit; see [Operator scopes](/gateway/operator-scopes).
141143
- `gateway.nodes.pairing.autoApproveCidrs` is an opt-in Gateway policy for
142144
fresh node device pairing only; it does not change CLI approval authority.
143145
- Token rotation and revocation stay inside the approved pairing role set and

docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1497,6 +1497,7 @@
14971497
"pages": [
14981498
"gateway/security/index",
14991499
"gateway/security/audit-checks",
1500+
"gateway/operator-scopes",
15001501
"gateway/sandboxing",
15011502
"gateway/openshell",
15021503
"gateway/sandbox-vs-tool-policy-vs-elevated"

docs/gateway/operator-scopes.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
---
2+
summary: "Operator roles, scopes, and approval-time checks for Gateway clients"
3+
read_when:
4+
- Debugging missing operator scope errors
5+
- Reviewing device or node pairing approvals
6+
- Adding or classifying Gateway RPC methods
7+
title: "Operator scopes"
8+
---
9+
10+
Operator scopes define what a Gateway client may do after it authenticates.
11+
They are a control-plane guardrail inside one trusted Gateway operator domain,
12+
not hostile multi-tenant isolation. If you need strong separation between
13+
people, teams, or machines, run separate Gateways under separate OS users or
14+
hosts.
15+
16+
Related: [Security](/gateway/security), [Gateway protocol](/gateway/protocol),
17+
[Gateway pairing](/gateway/pairing), [Devices CLI](/cli/devices).
18+
19+
## Roles
20+
21+
Gateway WebSocket clients connect with one role:
22+
23+
- `operator`: control-plane clients such as CLI, Control UI, automation, and
24+
trusted helper processes.
25+
- `node`: capability hosts such as macOS, iOS, Android, or headless nodes that
26+
expose commands through `node.invoke`.
27+
28+
Operator RPC methods require the `operator` role. Node-originated methods
29+
require the `node` role.
30+
31+
## Scope levels
32+
33+
| Scope | Meaning |
34+
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
35+
| `operator.read` | Read-only status, lists, catalog, logs, session reads, and other non-mutating control-plane calls. |
36+
| `operator.write` | Normal mutating operator actions such as sending messages, invoking tools, updating talk/voice settings, and node command relay. Also satisfies `operator.read`. |
37+
| `operator.admin` | Administrative control-plane access. Satisfies every `operator.*` scope. Required for config mutation, updates, native hooks, sensitive reserved namespaces, and high-risk approvals. |
38+
| `operator.pairing` | Device and node pairing management, including listing, approving, rejecting, removing, rotating, and revoking pairing records or device tokens. |
39+
| `operator.approvals` | Exec and plugin approval APIs. |
40+
| `operator.talk.secrets` | Reading Talk configuration with secrets included. |
41+
42+
Unknown future `operator.*` scopes require an exact match unless the caller has
43+
`operator.admin`.
44+
45+
## Method scope is only the first gate
46+
47+
Each Gateway RPC has a least-privilege method scope. That method scope decides
48+
whether the request can reach the handler. Some handlers then apply stricter
49+
approval-time checks based on the concrete thing being approved or mutated.
50+
51+
Examples:
52+
53+
- `device.pair.approve` is reachable with `operator.pairing`, but approving an
54+
operator device can only mint or preserve scopes the caller already holds.
55+
- `node.pair.approve` is reachable with `operator.pairing`, then derives extra
56+
approval scopes from the pending node command list.
57+
- `chat.send` is normally a write-scoped method, but persistent `/config set`
58+
and `/config unset` require `operator.admin` at command level.
59+
60+
This lets lower-scope operators perform low-risk pairing actions without making
61+
all pairing approval admin-only.
62+
63+
## Device pairing approvals
64+
65+
Device pairing records are the durable source of approved roles and scopes.
66+
Already paired devices do not get broader access silently: reconnects that ask
67+
for a broader role or broader scopes create a new pending upgrade request.
68+
69+
When approving a device request:
70+
71+
- A request with no operator role does not need operator token scope approval.
72+
- A request for `operator.read`, `operator.write`, `operator.approvals`,
73+
`operator.pairing`, or `operator.talk.secrets` requires the caller to hold
74+
those scopes, or `operator.admin`.
75+
- A request for `operator.admin` requires `operator.admin`.
76+
- A repair request with no explicit scopes can inherit the existing operator
77+
token scopes. If that existing token is admin-scoped, approval still requires
78+
`operator.admin`.
79+
80+
For paired-device token sessions, management is self-scoped unless the caller
81+
also has `operator.admin`: non-admin callers can rotate, revoke, or remove only
82+
their own device entry.
83+
84+
## Node pairing approvals
85+
86+
Legacy `node.pair.*` uses a separate Gateway-owned node pairing store. WS nodes
87+
use device pairing with `role: node`, but the same approval-level vocabulary
88+
applies.
89+
90+
`node.pair.approve` uses the pending request command list to derive additional
91+
required scopes:
92+
93+
- Commandless request: `operator.pairing`
94+
- Non-exec node commands: `operator.pairing` + `operator.write`
95+
- `system.run`, `system.run.prepare`, or `system.which`:
96+
`operator.pairing` + `operator.admin`
97+
98+
Node pairing establishes identity and trust. It does not replace the node's
99+
own `system.run` exec approval policy.
100+
101+
## Shared-secret auth
102+
103+
Shared gateway token/password auth is treated as trusted operator access for
104+
that Gateway. OpenAI-compatible HTTP surfaces and `/tools/invoke` restore the
105+
normal full operator default scope set for shared-secret bearer auth, even if a
106+
caller sends narrower declared scopes.
107+
108+
Identity-bearing modes, such as trusted proxy auth or private-ingress `none`,
109+
can still honor explicit declared scopes. Use separate Gateways for real trust
110+
boundary separation.

docs/gateway/pairing.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ Notes:
6969
metadata and the latest allowlisted declared command snapshot for operator visibility.
7070
- Approval **always** generates a fresh token; no token is ever returned from
7171
`node.pair.request`.
72+
- Operator scope levels and approval-time checks are summarized in
73+
[Operator scopes](/gateway/operator-scopes).
7274
- Requests may include `silent: true` as a hint for auto-approval flows.
7375
- `node.pair.approve` uses the pending request's declared commands to enforce
7476
extra approval scopes:

docs/gateway/protocol.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,9 @@ Side-effecting methods require **idempotency keys** (see schema).
211211

212212
## Roles + scopes
213213

214+
For the full operator scope model, approval-time checks, and shared-secret
215+
semantics, see [Operator scopes](/gateway/operator-scopes).
216+
214217
### Roles
215218

216219
- `operator` = control plane client (CLI/UI/automation).

docs/gateway/security/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ Treat Gateway and node as one operator trust domain, with different roles:
9292
- **Gateway** is the control plane and policy surface (`gateway.auth`, tool policy, routing).
9393
- **Node** is remote execution surface paired to that Gateway (commands, device actions, host-local capabilities).
9494
- A caller authenticated to the Gateway is trusted at Gateway scope. After pairing, node actions are trusted operator actions on that node.
95+
- Operator scope levels and approval-time checks are summarized in
96+
[Operator scopes](/gateway/operator-scopes).
9597
- Direct loopback backend clients authenticated with the shared gateway
9698
token/password can make internal control-plane RPCs without presenting a user
9799
device identity. This is not a remote or browser pairing bypass: network

src/cli/devices-cli.test.ts

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,16 +119,83 @@ function mockLocalPairingFallback(message?: string) {
119119
}
120120

121121
describe("devices cli approve", () => {
122-
it("approves an explicit request id without listing", async () => {
123-
callGateway.mockResolvedValueOnce({ device: { deviceId: "device-1" } });
122+
it("uses admin scope when approving an admin-scope request", async () => {
123+
callGateway
124+
.mockResolvedValueOnce({
125+
pending: [pendingDevice({ requestId: "req-123", scopes: ["operator.admin"] })],
126+
paired: [],
127+
})
128+
.mockResolvedValueOnce({ device: { deviceId: "device-1" } });
124129

125130
await runDevicesApprove(["req-123"]);
126131

127-
expect(callGateway).toHaveBeenCalledTimes(1);
128-
expect(callGateway).toHaveBeenCalledWith(
132+
expect(callGateway).toHaveBeenCalledTimes(2);
133+
expect(callGateway).toHaveBeenNthCalledWith(
134+
1,
135+
expect.objectContaining({
136+
method: "device.pair.list",
137+
}),
138+
);
139+
expect(callGateway).toHaveBeenNthCalledWith(
140+
2,
129141
expect.objectContaining({
130142
method: "device.pair.approve",
131143
params: { requestId: "req-123" },
144+
scopes: ["operator.admin"],
145+
}),
146+
);
147+
});
148+
149+
it("keeps pairing scope for non-admin device approvals", async () => {
150+
callGateway
151+
.mockResolvedValueOnce({
152+
pending: [
153+
pendingDevice({
154+
requestId: "req-pairing",
155+
scopes: ["operator.pairing"],
156+
}),
157+
],
158+
paired: [],
159+
})
160+
.mockResolvedValueOnce({ device: { deviceId: "device-1" } });
161+
162+
await runDevicesApprove(["req-pairing"]);
163+
164+
expect(callGateway).toHaveBeenNthCalledWith(
165+
2,
166+
expect.objectContaining({
167+
method: "device.pair.approve",
168+
params: { requestId: "req-pairing" },
169+
scopes: ["operator.pairing"],
170+
}),
171+
);
172+
});
173+
174+
it("uses admin scope when a repair approval would inherit an admin token", async () => {
175+
callGateway
176+
.mockResolvedValueOnce({
177+
pending: [
178+
pendingDevice({
179+
requestId: "req-repair",
180+
scopes: [],
181+
}),
182+
],
183+
paired: [
184+
pairedDevice({
185+
tokens: [{ role: "operator", scopes: ["operator.admin"] }],
186+
}),
187+
],
188+
})
189+
.mockResolvedValueOnce({ device: { deviceId: "device-1" } });
190+
191+
await runDevicesApprove(["req-repair"]);
192+
193+
expect(callGateway).toHaveBeenNthCalledWith(
194+
2,
195+
expect.objectContaining({
196+
method: "device.pair.approve",
197+
params: { requestId: "req-repair" },
198+
scopes: ["operator.admin"],
132199
}),
133200
);
134201
});
@@ -462,6 +529,7 @@ describe("devices cli local fallback", () => {
462529
});
463530

464531
it("falls back to local approve when gateway returns pairing required on loopback", async () => {
532+
mockLocalPairingFallback();
465533
rejectGatewayForLocalFallback();
466534
approveDevicePairing.mockResolvedValueOnce({
467535
requestId: "req-latest",

0 commit comments

Comments
 (0)