Skip to content

Commit 4ee4960

Browse files
Pairing: forward caller scopes during approval (#55950)
* Pairing: require caller scopes on approvals * Gateway: reject forbidden silent pairing results
1 parent 2e23d44 commit 4ee4960

14 files changed

Lines changed: 148 additions & 27 deletions

extensions/device-pair/index.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,43 @@ describe("device-pair /pair approve", () => {
510510
}),
511511
);
512512

513-
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1");
513+
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1", {
514+
callerScopes: ["operator.write", "operator.pairing"],
515+
});
514516
expect(result).toEqual({ text: "✅ Paired Victim Phone (ios)." });
515517
});
518+
519+
it("rejects approvals above the caller scopes", async () => {
520+
vi.mocked(listDevicePairing).mockResolvedValueOnce({
521+
pending: [
522+
{
523+
requestId: "req-1",
524+
deviceId: "victim-phone",
525+
publicKey: "victim-public-key",
526+
displayName: "Victim Phone",
527+
platform: "ios",
528+
ts: Date.now(),
529+
},
530+
],
531+
paired: [],
532+
});
533+
vi.mocked(approveDevicePairing).mockResolvedValueOnce({
534+
status: "forbidden",
535+
missingScope: "operator.admin",
536+
});
537+
538+
const command = registerPairCommand();
539+
const result = await command.handler(
540+
createCommandContext({
541+
channel: "webchat",
542+
args: "approve latest",
543+
commandBody: "/pair approve latest",
544+
gatewayClientScopes: ["operator.write", "operator.pairing"],
545+
}),
546+
);
547+
548+
expect(result).toEqual({
549+
text: "⚠️ Cannot approve a request requiring operator.admin.",
550+
});
551+
});
516552
});

extensions/device-pair/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -611,10 +611,15 @@ export default definePluginEntry({
611611
if (!pending) {
612612
return { text: "Pairing request not found." };
613613
}
614-
const approved = await approveDevicePairing(pending.requestId);
614+
const approved = await approveDevicePairing(pending.requestId, {
615+
callerScopes: gatewayClientScopes ?? [],
616+
});
615617
if (!approved) {
616618
return { text: "Pairing request not found." };
617619
}
620+
if (approved.status === "forbidden") {
621+
return { text: `⚠️ Cannot approve a request requiring ${approved.missingScope}.` };
622+
}
618623
const label = approved.device.displayName?.trim() || approved.device.deviceId;
619624
const platform = approved.device.platform?.trim();
620625
const platformLabel = platform ? ` (${platform})` : "";

src/cli/devices-cli.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,9 @@ describe("devices cli local fallback", () => {
280280

281281
await runDevicesApprove(["--latest"]);
282282

283-
expect(approveDevicePairing).toHaveBeenCalledWith("req-latest");
283+
expect(approveDevicePairing).toHaveBeenCalledWith("req-latest", {
284+
callerScopes: ["operator.admin"],
285+
});
284286
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining(fallbackNotice));
285287
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Approved"));
286288
});

src/cli/devices-cli.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,17 @@ async function approvePairingWithFallback(
159159
if (opts.json !== true) {
160160
defaultRuntime.log(theme.warn(FALLBACK_NOTICE));
161161
}
162-
const approved = await approveDevicePairing(requestId);
162+
const approved = await approveDevicePairing(requestId, {
163+
// Local CLI fallback already assumes direct machine access; treat it as an
164+
// explicit admin approval path instead of relying on missing caller scopes.
165+
callerScopes: ["operator.admin"],
166+
});
163167
if (!approved) {
164168
return null;
165169
}
170+
if (approved.status === "forbidden") {
171+
throw new Error(`missing scope: ${approved.missingScope}`, { cause: error });
172+
}
166173
return {
167174
requestId,
168175
device: redactLocalPairedDevice(approved.device),

src/gateway/device-authz.test-helpers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ export async function pairDeviceIdentity(params: {
5454
clientId: params.clientId,
5555
clientMode: params.clientMode,
5656
});
57-
await approveDevicePairing(request.request.requestId);
57+
await approveDevicePairing(request.request.requestId, {
58+
callerScopes: params.scopes,
59+
});
5860
return loaded;
5961
}
6062

src/gateway/server.auth.compat-baseline.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,9 @@ describe("gateway auth compatibility baseline", () => {
167167
role: "operator",
168168
scopes: ["operator.admin"],
169169
});
170-
await approveDevicePairing(pending.request.requestId);
170+
await approveDevicePairing(pending.request.requestId, {
171+
callerScopes: ["operator.admin"],
172+
});
171173

172174
const rotated = await rotateDeviceToken({
173175
deviceId: identity.deviceId,

src/gateway/server.auth.control-ui.suite.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,9 @@ export function registerControlUiAndPairingSuite(): void {
201201
displayName: params.displayName,
202202
platform: params.platform,
203203
});
204-
await approveDevicePairing(seeded.request.requestId);
204+
await approveDevicePairing(seeded.request.requestId, {
205+
callerScopes: ["operator.admin"],
206+
});
205207
return { identityPath, identity: { deviceId: identity.deviceId } };
206208
};
207209

@@ -761,7 +763,9 @@ export function registerControlUiAndPairingSuite(): void {
761763
if (!pendingForTestDevice[0]) {
762764
throw new Error("expected pending pairing request");
763765
}
764-
await approveDevicePairing(pendingForTestDevice[0].requestId);
766+
await approveDevicePairing(pendingForTestDevice[0].requestId, {
767+
callerScopes: pendingForTestDevice[0].scopes ?? ["operator.admin"],
768+
});
765769

766770
const paired = await getPairedDevice(identity.deviceId);
767771
expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
@@ -843,7 +847,9 @@ export function registerControlUiAndPairingSuite(): void {
843847
displayName: "legacy-test",
844848
platform: "test",
845849
});
846-
await approveDevicePairing(pending.request.requestId);
850+
await approveDevicePairing(pending.request.requestId, {
851+
callerScopes: pending.request.scopes ?? ["operator.admin"],
852+
});
847853

848854
await stripPairedMetadataRolesAndScopes(deviceId);
849855

src/gateway/server.auth.shared.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,9 @@ async function approvePendingPairingIfNeeded() {
212212
const pending = list.pending.at(0);
213213
expect(pending?.requestId).toBeDefined();
214214
if (pending?.requestId) {
215-
await approveDevicePairing(pending.requestId);
215+
await approveDevicePairing(pending.requestId, {
216+
callerScopes: pending.scopes ?? ["operator.admin"],
217+
});
216218
}
217219
}
218220

src/gateway/server.node-invoke-approval-bypass.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,9 @@ describe("node.invoke approval bypass", () => {
117117
const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js");
118118
const list = await listDevicePairing();
119119
for (const pending of list.pending) {
120-
await approveDevicePairing(pending.requestId);
120+
await approveDevicePairing(pending.requestId, {
121+
callerScopes: pending.scopes ?? ["operator.admin"],
122+
});
121123
}
122124
};
123125

src/gateway/server.roles-allowlist-update.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@ const approveAllPendingPairings = async () => {
7272
const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js");
7373
const list = await listDevicePairing();
7474
for (const pending of list.pending) {
75-
await approveDevicePairing(pending.requestId);
75+
await approveDevicePairing(pending.requestId, {
76+
callerScopes: pending.scopes ?? ["operator.admin"],
77+
});
7678
}
7779
};
7880

0 commit comments

Comments
 (0)