fix(device-pairing): validate callerScopes against resolved token scopes on repair [AI] (#72925)

* fix: address issue

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-04-28 18:31:05 +05:30
committed by GitHub
parent 037f197684
commit 189c91eae6
3 changed files with 61 additions and 23 deletions

View File

@@ -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.

View File

@@ -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"]);

View File

@@ -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<string, string[]>();
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,
};