Skip to content

Commit 0d0bddf

Browse files
authored
fix(gateway): require admin for device role approvals (#87146)
* fix(gateway): require admin for device role approvals * fix(gateway): add trusted-proxy approval proof
1 parent 9159013 commit 0d0bddf

4 files changed

Lines changed: 36 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
1313

1414
- Block unsafe Node runtime env overrides [AI]. (#87308) Thanks @pgondhi987.
1515
- Telegram: route `sendMessage` action replies through durable outbound delivery so completed agent responses remain retryable when the gateway send path times out. (#87261) Thanks @mbelinky.
16+
- Gateway/security: require `operator.admin` for node and other non-operator device-role pairing approvals, including trusted-proxy sessions, while keeping pairing-only approvals available for operator-role requests. (#87146)
1617

1718
## 2026.5.26
1819

docs/cli/devices.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,10 @@ matching client IPs can be approved before they appear in this list. That policy
7171
is disabled by default and never applies to operator/browser clients or upgrade
7272
requests.
7373

74-
Approving a non-operator device role, such as `role: node`, requires
75-
`operator.admin`. `operator.pairing` is enough for operator-device approvals
76-
only when the requested operator scopes stay within the caller's own scopes.
74+
Approving node or other non-operator device roles requires `operator.admin`.
75+
`operator.pairing` is enough for operator-device approvals only when the
76+
requested operator scopes stay within the caller's own scopes. See
77+
[Operator scopes](/gateway/operator-scopes) for the approval-time checks.
7778

7879
```
7980
openclaw devices approve

docs/gateway/operator-scopes.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ When approving a device request:
7070

7171
- A request with no operator role does not need operator token scope approval.
7272
- A request for a non-operator device role, such as `node`, requires
73-
`operator.admin`.
73+
`operator.admin`, even when `device.pair.approve` is reachable with
74+
`operator.pairing`.
7475
- A request for `operator.read`, `operator.write`, `operator.approvals`,
7576
`operator.pairing`, or `operator.talk.secrets` requires the caller to hold
7677
those scopes, or `operator.admin`.
@@ -79,9 +80,9 @@ When approving a device request:
7980
token scopes. If that existing token is admin-scoped, approval still requires
8081
`operator.admin`.
8182

82-
Non-admin sessions can approve operator-device requests only inside their own
83-
operator scopes. Approving non-operator roles is admin-only even for
84-
shared-secret or trusted-proxy sessions that can otherwise use
83+
Non-admin shared-secret and trusted-proxy sessions can approve operator-device
84+
requests only inside their own declared operator scopes. Approving non-operator
85+
roles is admin-only even when those sessions can otherwise use
8586
`operator.pairing`.
8687

8788
For paired-device token sessions, management is also self-scoped unless the

src/gateway/server-methods/devices.test.ts

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -888,7 +888,7 @@ describe("deviceHandlers", () => {
888888
);
889889
});
890890

891-
it("allows non-device operator sessions to approve operator roles within caller scopes", async () => {
891+
it("allows shared-auth operator sessions to approve operator roles within caller scopes", async () => {
892892
getPendingDevicePairingMock.mockResolvedValue({
893893
requestId: "req-1",
894894
deviceId: "device-2",
@@ -914,7 +914,7 @@ describe("deviceHandlers", () => {
914914
const opts = createOptions(
915915
"device.pair.approve",
916916
{ requestId: "req-1" },
917-
{ client: createClient(["operator.pairing"]) },
917+
{ client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: false }) },
918918
);
919919

920920
await deviceHandlers["device.pair.approve"](opts);
@@ -941,19 +941,40 @@ describe("deviceHandlers", () => {
941941
);
942942
});
943943

944+
it("rejects approving node roles for the caller device without admin scope", async () => {
945+
getPendingDevicePairingMock.mockResolvedValue({
946+
requestId: "req-1",
947+
deviceId: " device-1 ",
948+
publicKey: "pk-1",
949+
role: "node",
950+
roles: ["node"],
951+
ts: 100,
952+
});
953+
const opts = createOptions(
954+
"device.pair.approve",
955+
{ requestId: "req-1" },
956+
{ client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) },
957+
);
958+
959+
await deviceHandlers["device.pair.approve"](opts);
960+
961+
expect(approveDevicePairingMock).not.toHaveBeenCalled();
962+
expectRespondedErrorMessage(opts, "device pairing approval denied");
963+
});
964+
944965
it("rejects approving node roles from non-admin shared-auth sessions", async () => {
945966
getPendingDevicePairingMock.mockResolvedValue({
946967
requestId: "req-1",
947-
deviceId: "device-2",
948-
publicKey: "pk-2",
968+
deviceId: "device-1",
969+
publicKey: "pk-1",
949970
role: "node",
950971
roles: ["node"],
951972
ts: 100,
952973
});
953974
const opts = createOptions(
954975
"device.pair.approve",
955976
{ requestId: "req-1" },
956-
{ client: createClient(["operator.pairing"]) },
977+
{ client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: false }) },
957978
);
958979

959980
await deviceHandlers["device.pair.approve"](opts);
@@ -998,27 +1019,6 @@ describe("deviceHandlers", () => {
9981019
expectRespondedErrorMessage(opts, "device pairing approval denied");
9991020
});
10001021

1001-
it("rejects approving node roles for the caller device without admin scope", async () => {
1002-
getPendingDevicePairingMock.mockResolvedValue({
1003-
requestId: "req-1",
1004-
deviceId: " device-1 ",
1005-
publicKey: "pk-1",
1006-
role: "node",
1007-
roles: ["node"],
1008-
ts: 100,
1009-
});
1010-
const opts = createOptions(
1011-
"device.pair.approve",
1012-
{ requestId: "req-1" },
1013-
{ client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) },
1014-
);
1015-
1016-
await deviceHandlers["device.pair.approve"](opts);
1017-
1018-
expect(approveDevicePairingMock).not.toHaveBeenCalled();
1019-
expectRespondedErrorMessage(opts, "device pairing approval denied");
1020-
});
1021-
10221022
it("rejects rejecting another device from a non-admin device session", async () => {
10231023
getPendingDevicePairingMock.mockResolvedValue({
10241024
requestId: "req-2",

0 commit comments

Comments
 (0)