diff --git a/CHANGELOG.md b/CHANGELOG.md index be0519399c2..adfa721810a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- fix(device-pairing): validate callerScopes against resolved token scopes on repair [AI]. (#72925) Thanks @pgondhi987. - fix(agents): canonicalize provider aliases in byProvider tool policy lookup [AI]. (#72917) Thanks @pgondhi987. - fix(security): block npm_execpath injection from workspace .env [AI-assisted]. (#73262) Thanks @pgondhi987. - Tools/web_fetch: decode response bodies from raw bytes using declared HTTP, XML, or HTML meta charsets before extraction, so Shift_JIS and other legacy-charset pages no longer return mojibake. Fixes #72916. Thanks @amknight. diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index b0bbac3c0ab..b9c9f6bf6ab 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -681,6 +681,41 @@ describe("device pairing tokens", () => { ]); }); + test("rejects repair without requested scopes when caller cannot approve inherited token scopes", async () => { + const baseDir = await makeDevicePairingDir(); + await setupPairedOperatorDevice(baseDir, ["operator.admin"]); + const before = await getPairedDevice("device-1", baseDir); + + const repair = await requestDevicePairing( + { + deviceId: "device-1", + publicKey: "public-key-1", + role: "operator", + }, + baseDir, + ); + + await expect( + approveDevicePairing( + repair.request.requestId, + { callerScopes: ["operator.pairing"] }, + baseDir, + ), + ).resolves.toEqual({ + status: "forbidden", + reason: "caller-missing-scope", + scope: "operator.admin", + }); + + const after = await getPairedDevice("device-1", baseDir); + expect(after?.tokens?.operator?.token).toEqual(before?.tokens?.operator?.token); + expect(after?.tokens?.operator?.scopes).toEqual([ + "operator.admin", + "operator.read", + "operator.write", + ]); + }); + test("rejects scope escalation when rotating a token and leaves state unchanged", async () => { const baseDir = await makeDevicePairingDir(); await setupPairedOperatorDevice(baseDir, ["operator.read"]); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 6e283a97c90..2a44e3deb5f 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -593,26 +593,6 @@ export async function approveDevicePairing( scope: roleMismatchScope, }; } - const requestedOperatorScopes = requestedScopes.filter((scope) => - scope.startsWith(OPERATOR_SCOPE_PREFIX), - ); - if (requestedOperatorScopes.length > 0) { - if (!options?.callerScopes) { - return { - status: "forbidden", - reason: "caller-scopes-required", - scope: requestedOperatorScopes[0], - }; - } - const missingScope = resolveMissingRequestedScope({ - role: OPERATOR_ROLE, - requestedScopes: requestedOperatorScopes, - allowedScopes: options.callerScopes, - }); - if (missingScope) { - return { status: "forbidden", reason: "caller-missing-scope", scope: missingScope }; - } - } const now = Date.now(); const existing = state.pairedByDeviceId[pending.deviceId]; const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role); @@ -621,6 +601,7 @@ export async function approveDevicePairing( pending.scopes, ); const tokens = existing?.tokens ? { ...existing.tokens } : {}; + const nextTokenScopesByRole = new Map(); for (const roleForToken of requestedRoles) { const existingToken = tokens[roleForToken]; const nextScopes = resolveApprovedTokenScopes({ @@ -630,13 +611,34 @@ export async function approveDevicePairing( approvedScopes, existing, }); - const now = Date.now(); + nextTokenScopesByRole.set(roleForToken, nextScopes); + if (roleForToken === OPERATOR_ROLE && nextScopes.length > 0) { + if (!options?.callerScopes) { + return { + status: "forbidden", + reason: "caller-scopes-required", + scope: nextScopes[0], + }; + } + const missingScope = resolveMissingRequestedScope({ + role: OPERATOR_ROLE, + requestedScopes: nextScopes, + allowedScopes: options.callerScopes, + }); + if (missingScope) { + return { status: "forbidden", reason: "caller-missing-scope", scope: missingScope }; + } + } + } + for (const [roleForToken, nextScopes] of nextTokenScopesByRole) { + const existingToken = tokens[roleForToken]; + const tokenNow = Date.now(); tokens[roleForToken] = { token: newToken(), role: roleForToken, scopes: nextScopes, - createdAtMs: existingToken?.createdAtMs ?? now, - rotatedAtMs: existingToken ? now : undefined, + createdAtMs: existingToken?.createdAtMs ?? tokenNow, + rotatedAtMs: existingToken ? tokenNow : undefined, revokedAtMs: undefined, lastUsedAtMs: existingToken?.lastUsedAtMs, };