fix: finalize device-pair scope hardening (#55996) (thanks @coygeek)

This commit is contained in:
Peter Steinberger
2026-04-04 19:43:33 +09:00
parent 9dcef6df02
commit 20a7b1a9dc
2 changed files with 8 additions and 45 deletions

View File

@@ -84,6 +84,7 @@ Docs: https://docs.openclaw.ai
- MiniMax/pricing: keep bundled MiniMax highspeed pricing distinct in provider catalogs and preserve the lower M2.5 cache-read pricing when onboarding older MiniMax models. (#54214) Thanks @octo-patch.
- Agents/cache: preserve the full 3-turn prompt-cache image window across tool loops, keep colliding bundled MCP tool definitions deterministic, and reapply Anthropic Vertex cache shaping after payload hook replacements so KV/cache reuse stays stable. Thanks @vincentkoc.
- Device pairing: reject rotating device tokens into roles that were never approved during pairing, and keep reconnect role checks bounded to the paired device's approved role set. (#60462) Thanks @eleqtrizit.
- Mobile pairing/security: fail closed for internal `/pair` setup-code issuance, cleanup, and approval paths when gateway pairing scopes are missing, and keep approval-time requested-scope enforcement on the internal command path. (#55996) Thanks @coygeek.
## 2026.4.2

View File

@@ -411,6 +411,7 @@ describe("device-pair /pair qr", () => {
const command = registerPairCommand();
const result = await command?.handler(
createCommandContext({
channel: "telegram",
args: "cleanup",
commandBody: "/pair cleanup",
}),
@@ -560,6 +561,10 @@ describe("device-pair notify pending formatting", () => {
});
describe("device-pair /pair approve", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("rejects internal gateway callers without operator.pairing", async () => {
vi.mocked(listDevicePairing).mockResolvedValueOnce({
pending: [
@@ -713,10 +718,6 @@ describe("device-pair /pair approve", () => {
],
paired: [],
});
vi.mocked(approveDevicePairing).mockResolvedValueOnce({
status: "forbidden",
missingScope: "operator.admin",
});
const command = registerPairCommand();
const result = await command.handler(
@@ -728,11 +729,9 @@ describe("device-pair /pair approve", () => {
}),
);
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1", {
callerScopes: [],
});
expect(vi.mocked(approveDevicePairing)).not.toHaveBeenCalled();
expect(result).toEqual({
text: "⚠️ This command requires operator.admin to approve this pairing request.",
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
});
});
@@ -773,43 +772,6 @@ describe("device-pair /pair approve", () => {
});
});
it("fails closed for internal gateway callers when scopes are absent", async () => {
vi.mocked(listDevicePairing).mockResolvedValueOnce({
pending: [
{
requestId: "req-1",
deviceId: "victim-phone",
publicKey: "victim-public-key",
displayName: "Victim Phone",
platform: "ios",
ts: Date.now(),
},
],
paired: [],
});
vi.mocked(approveDevicePairing).mockImplementationOnce(async () => ({
status: "forbidden",
missingScope: "operator.admin",
}));
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "approve latest",
commandBody: "/pair approve latest",
gatewayClientScopes: undefined,
}),
);
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1", {
callerScopes: [],
});
expect(result).toEqual({
text: "⚠️ This command requires operator.admin to approve this pairing request.",
});
});
it("preserves approvals for non-gateway command surfaces", async () => {
vi.mocked(listDevicePairing).mockResolvedValueOnce({
pending: [