Pairing: forward caller scopes during approval (#55950)

* Pairing: require caller scopes on approvals

* Gateway: reject forbidden silent pairing results
This commit is contained in:
Jacob Tomlinson
2026-03-27 11:55:33 -07:00
committed by GitHub
parent 2e23d44491
commit 4ee4960de2
14 changed files with 148 additions and 27 deletions

View File

@@ -510,7 +510,43 @@ describe("device-pair /pair approve", () => {
}),
);
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1");
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1", {
callerScopes: ["operator.write", "operator.pairing"],
});
expect(result).toEqual({ text: "✅ Paired Victim Phone (ios)." });
});
it("rejects approvals above the caller scopes", 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).mockResolvedValueOnce({
status: "forbidden",
missingScope: "operator.admin",
});
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "approve latest",
commandBody: "/pair approve latest",
gatewayClientScopes: ["operator.write", "operator.pairing"],
}),
);
expect(result).toEqual({
text: "⚠️ Cannot approve a request requiring operator.admin.",
});
});
});

View File

@@ -611,10 +611,15 @@ export default definePluginEntry({
if (!pending) {
return { text: "Pairing request not found." };
}
const approved = await approveDevicePairing(pending.requestId);
const approved = await approveDevicePairing(pending.requestId, {
callerScopes: gatewayClientScopes ?? [],
});
if (!approved) {
return { text: "Pairing request not found." };
}
if (approved.status === "forbidden") {
return { text: `⚠️ Cannot approve a request requiring ${approved.missingScope}.` };
}
const label = approved.device.displayName?.trim() || approved.device.deviceId;
const platform = approved.device.platform?.trim();
const platformLabel = platform ? ` (${platform})` : "";

View File

@@ -280,7 +280,9 @@ describe("devices cli local fallback", () => {
await runDevicesApprove(["--latest"]);
expect(approveDevicePairing).toHaveBeenCalledWith("req-latest");
expect(approveDevicePairing).toHaveBeenCalledWith("req-latest", {
callerScopes: ["operator.admin"],
});
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining(fallbackNotice));
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Approved"));
});

View File

@@ -159,10 +159,17 @@ async function approvePairingWithFallback(
if (opts.json !== true) {
defaultRuntime.log(theme.warn(FALLBACK_NOTICE));
}
const approved = await approveDevicePairing(requestId);
const approved = await approveDevicePairing(requestId, {
// Local CLI fallback already assumes direct machine access; treat it as an
// explicit admin approval path instead of relying on missing caller scopes.
callerScopes: ["operator.admin"],
});
if (!approved) {
return null;
}
if (approved.status === "forbidden") {
throw new Error(`missing scope: ${approved.missingScope}`, { cause: error });
}
return {
requestId,
device: redactLocalPairedDevice(approved.device),

View File

@@ -54,7 +54,9 @@ export async function pairDeviceIdentity(params: {
clientId: params.clientId,
clientMode: params.clientMode,
});
await approveDevicePairing(request.request.requestId);
await approveDevicePairing(request.request.requestId, {
callerScopes: params.scopes,
});
return loaded;
}

View File

@@ -167,7 +167,9 @@ describe("gateway auth compatibility baseline", () => {
role: "operator",
scopes: ["operator.admin"],
});
await approveDevicePairing(pending.request.requestId);
await approveDevicePairing(pending.request.requestId, {
callerScopes: ["operator.admin"],
});
const rotated = await rotateDeviceToken({
deviceId: identity.deviceId,

View File

@@ -201,7 +201,9 @@ export function registerControlUiAndPairingSuite(): void {
displayName: params.displayName,
platform: params.platform,
});
await approveDevicePairing(seeded.request.requestId);
await approveDevicePairing(seeded.request.requestId, {
callerScopes: ["operator.admin"],
});
return { identityPath, identity: { deviceId: identity.deviceId } };
};
@@ -761,7 +763,9 @@ export function registerControlUiAndPairingSuite(): void {
if (!pendingForTestDevice[0]) {
throw new Error("expected pending pairing request");
}
await approveDevicePairing(pendingForTestDevice[0].requestId);
await approveDevicePairing(pendingForTestDevice[0].requestId, {
callerScopes: pendingForTestDevice[0].scopes ?? ["operator.admin"],
});
const paired = await getPairedDevice(identity.deviceId);
expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
@@ -843,7 +847,9 @@ export function registerControlUiAndPairingSuite(): void {
displayName: "legacy-test",
platform: "test",
});
await approveDevicePairing(pending.request.requestId);
await approveDevicePairing(pending.request.requestId, {
callerScopes: pending.request.scopes ?? ["operator.admin"],
});
await stripPairedMetadataRolesAndScopes(deviceId);

View File

@@ -212,7 +212,9 @@ async function approvePendingPairingIfNeeded() {
const pending = list.pending.at(0);
expect(pending?.requestId).toBeDefined();
if (pending?.requestId) {
await approveDevicePairing(pending.requestId);
await approveDevicePairing(pending.requestId, {
callerScopes: pending.scopes ?? ["operator.admin"],
});
}
}

View File

@@ -117,7 +117,9 @@ describe("node.invoke approval bypass", () => {
const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js");
const list = await listDevicePairing();
for (const pending of list.pending) {
await approveDevicePairing(pending.requestId);
await approveDevicePairing(pending.requestId, {
callerScopes: pending.scopes ?? ["operator.admin"],
});
}
};

View File

@@ -72,7 +72,9 @@ const approveAllPendingPairings = async () => {
const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js");
const list = await listDevicePairing();
for (const pending of list.pending) {
await approveDevicePairing(pending.requestId);
await approveDevicePairing(pending.requestId, {
callerScopes: pending.scopes ?? ["operator.admin"],
});
}
};

View File

@@ -94,7 +94,9 @@ beforeAll(async () => {
scopes: ["operator.admin", "operator.read", "operator.write", "operator.approvals"],
silent: false,
});
await approveDevicePairing(pending.request.requestId);
await approveDevicePairing(pending.request.requestId, {
callerScopes: pending.request.scopes ?? ["operator.admin"],
});
server = await startGatewayServer(gatewayPort);
});

View File

@@ -806,8 +806,10 @@ export function attachGatewayWsMessageHandler(params: {
return replacementPending?.requestId;
};
if (pairing.request.silent === true) {
approved = await approveDevicePairing(pairing.request.requestId);
if (approved) {
approved = await approveDevicePairing(pairing.request.requestId, {
callerScopes: scopes,
});
if (approved?.status === "approved") {
logGateway.info(
`device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
);
@@ -839,7 +841,12 @@ export function attachGatewayWsMessageHandler(params: {
}
// Re-resolve: another connection may have superseded/approved the request since we created it
recoveryRequestId = await resolveLivePendingRequestId();
if (!(pairing.request.silent === true && (approved || resolvedByConcurrentApproval))) {
if (
!(
pairing.request.silent === true &&
(approved?.status === "approved" || resolvedByConcurrentApproval)
)
) {
setHandshakeState("failed");
setCloseCause("pairing-required", {
deviceId: device.id,

View File

@@ -28,7 +28,7 @@ async function setupPairedOperatorDevice(baseDir: string, scopes: string[]) {
},
baseDir,
);
await approveDevicePairing(request.request.requestId, baseDir);
await approveDevicePairing(request.request.requestId, { callerScopes: scopes }, baseDir);
}
async function setupOperatorToken(scopes: string[]) {
@@ -158,7 +158,11 @@ describe("device pairing tokens", () => {
expect(list.pending).toHaveLength(1);
expect(list.pending[0]?.requestId).toBe(second.request.requestId);
await approveDevicePairing(second.request.requestId, baseDir);
await approveDevicePairing(
second.request.requestId,
{ callerScopes: ["operator.read", "operator.write"] },
baseDir,
);
const paired = await getPairedDevice("device-1", baseDir);
expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
expect(paired?.scopes).toEqual(expect.arrayContaining(["operator.read", "operator.write"]));
@@ -234,13 +238,50 @@ describe("device pairing tokens", () => {
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
await approveDevicePairing(first.request.requestId, baseDir);
await approveDevicePairing(
first.request.requestId,
{ callerScopes: ["operator.read"] },
baseDir,
);
const paired = await getPairedDevice("device-1", baseDir);
expect(paired?.scopes).toEqual(["operator.read"]);
expect(paired?.approvedScopes).toEqual(["operator.read"]);
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]);
});
test("fails closed for operator approvals when caller scopes are omitted", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
const request = await requestDevicePairing(
{
deviceId: "device-1",
publicKey: "public-key-1",
role: "operator",
scopes: ["operator.admin"],
},
baseDir,
);
await expect(approveDevicePairing(request.request.requestId, baseDir)).resolves.toEqual({
status: "forbidden",
missingScope: "operator.admin",
});
await expect(
approveDevicePairing(
request.request.requestId,
{
callerScopes: ["operator.admin"],
},
baseDir,
),
).resolves.toEqual(
expect.objectContaining({
status: "approved",
requestId: request.request.requestId,
}),
);
});
test("generates base64url device tokens with 256-bit entropy output length", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
@@ -289,7 +330,11 @@ describe("device pairing tokens", () => {
},
baseDir,
);
await approveDevicePairing(repair.request.requestId, baseDir);
await approveDevicePairing(
repair.request.requestId,
{ callerScopes: ["operator.admin"] },
baseDir,
);
const paired = await getPairedDevice("device-1", baseDir);
expect(paired?.scopes).toEqual(["operator.admin"]);

View File

@@ -85,11 +85,6 @@ export type ApproveDevicePairingResult =
| { status: "forbidden"; missingScope: string }
| null;
type ApprovedDevicePairingResult = Extract<
NonNullable<ApproveDevicePairingResult>,
{ status: "approved" }
>;
type DevicePairingStateFile = {
pendingById: Record<string, DevicePairingPendingRequest>;
pairedByDeviceId: Record<string, PairedDevice>;
@@ -445,7 +440,7 @@ export async function requestDevicePairing(
export async function approveDevicePairing(
requestId: string,
baseDir?: string,
): Promise<ApprovedDevicePairingResult | null>;
): Promise<ApproveDevicePairingResult>;
export async function approveDevicePairing(
requestId: string,
options: { callerScopes?: readonly string[] },
@@ -468,10 +463,16 @@ export async function approveDevicePairing(
return null;
}
const approvalRole = resolvePendingApprovalRole(pending);
if (approvalRole && options?.callerScopes) {
if (approvalRole) {
const requestedOperatorScopes = normalizeDeviceAuthScopes(pending.scopes).filter((scope) =>
scope.startsWith(OPERATOR_SCOPE_PREFIX),
);
if (!options?.callerScopes) {
return {
status: "forbidden",
missingScope: requestedOperatorScopes[0] ?? "callerScopes-required",
};
}
const missingScope = resolveMissingRequestedScope({
role: approvalRole,
requestedScopes: requestedOperatorScopes,