Skip to content

Commit be03ab5

Browse files
committed
fix(gateway): spurious manual scope-upgrade approval for local clients
A local, same-host operator client (e.g. an in-pod agent CLI) was interrupted with a manual scope-upgrade approval prompt mid-session, even though its initial pairing had been silently auto-approved on the same trusted local connection. It reproduces whenever such a client's first gateway call uses a read-scoped method and a later call needs an admin scope: e.g. an agent that calls cron.list (-> operator.read) before cron.add (-> operator.admin) is baselined at operator.read, so the cron.add trips a operator.read -> operator.admin upgrade that was forced onto the manual-approval path, stalling the agent on /approve. shouldAllowSilentLocalPairing() already treats a local scope-upgrade as silent-eligible (like initial pairing and role-upgrades), but the pairing call-site hard-forced silent:false for every scope-upgrade regardless of locality. Extract the decision into resolveDevicePairingSilent() and honor allowSilentLocalPairing for scope-upgrades too. Boundaries: only LOCAL scope upgrades become silent (allowSilentLocalPairing is false for remote clients, so remote escalations stay on the explicit-approval path); the trusted-CIDR and bootstrap node paths never silence a scope-upgrade. Net effect: a local scope-upgrade now behaves like a local role-upgrade already does.
1 parent e9720c2 commit be03ab5

3 files changed

Lines changed: 111 additions & 6 deletions

File tree

src/gateway/server/ws-connection/handshake-auth-helpers.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { AuthRateLimiter } from "../../auth-rate-limit.js";
99
import {
1010
BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX,
1111
BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP,
12+
resolveDevicePairingSilent,
1213
resolveHandshakeBrowserSecurityContext,
1314
resolvePairingLocality,
1415
resolveUnauthorizedHandshakeContext,
@@ -621,3 +622,72 @@ describe("handshake auth helpers", () => {
621622
).toBe("cli_container_local");
622623
});
623624
});
625+
626+
describe("resolveDevicePairingSilent", () => {
627+
const base = {
628+
allowSilentLocalPairing: false,
629+
allowSilentTrustedCidrsNodePairing: false,
630+
allowSilentBootstrapPairing: false,
631+
};
632+
633+
it("auto-approves a local scope-upgrade silently (read -> admin for an in-pod CLI)", () => {
634+
expect(
635+
resolveDevicePairingSilent({
636+
...base,
637+
reason: "scope-upgrade",
638+
allowSilentLocalPairing: true,
639+
}),
640+
).toBe(true);
641+
});
642+
643+
it("keeps a remote scope-upgrade on the explicit-approval path", () => {
644+
// allowSilentLocalPairing is false for remote clients (shouldAllowSilentLocalPairing).
645+
expect(
646+
resolveDevicePairingSilent({
647+
...base,
648+
reason: "scope-upgrade",
649+
}),
650+
).toBe(false);
651+
});
652+
653+
it("does not let the trusted-CIDR or bootstrap node paths silence a scope-upgrade", () => {
654+
expect(
655+
resolveDevicePairingSilent({
656+
reason: "scope-upgrade",
657+
allowSilentLocalPairing: false,
658+
allowSilentTrustedCidrsNodePairing: true,
659+
allowSilentBootstrapPairing: true,
660+
}),
661+
).toBe(false);
662+
});
663+
664+
it.each(["not-paired", "role-upgrade", "metadata-upgrade"] as const)(
665+
"auto-approves %s silently when any silent condition holds",
666+
(reason) => {
667+
expect(
668+
resolveDevicePairingSilent({ ...base, reason, allowSilentLocalPairing: true }),
669+
).toBe(true);
670+
expect(
671+
resolveDevicePairingSilent({
672+
...base,
673+
reason,
674+
allowSilentTrustedCidrsNodePairing: true,
675+
}),
676+
).toBe(true);
677+
expect(
678+
resolveDevicePairingSilent({ ...base, reason, allowSilentBootstrapPairing: true }),
679+
).toBe(true);
680+
},
681+
);
682+
683+
it("requires approval when no silent condition holds", () => {
684+
for (const reason of [
685+
"not-paired",
686+
"role-upgrade",
687+
"scope-upgrade",
688+
"metadata-upgrade",
689+
] as const) {
690+
expect(resolveDevicePairingSilent({ ...base, reason })).toBe(false);
691+
}
692+
});
693+
});

src/gateway/server/ws-connection/handshake-auth-helpers.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,40 @@ export function shouldAllowSilentLocalPairing(params: {
117117
return false;
118118
}
119119

120+
/**
121+
* Decide whether a device-pairing request may be auto-approved silently.
122+
*
123+
* A scope-upgrade (e.g. operator.read -> operator.admin, which happens when a
124+
* CLI device's first gateway call was a read method and a later call needs an
125+
* admin scope) is auto-approvable under the SAME local-trust condition that
126+
* already governs initial pairing and role-upgrades — i.e. only when the client
127+
* is local (`allowSilentLocalPairing`, which is false for remote clients). It is
128+
* never silently granted via the trusted-CIDR node path or the bootstrap path,
129+
* both of which target initial node onboarding rather than scope escalation.
130+
*
131+
* This keeps remote scope escalations on the explicit-approval path while
132+
* removing the spurious manual-approval prompt for same-host/in-pod CLI clients
133+
* whose initial pairing was already silently auto-approved.
134+
* `shouldAllowSilentLocalPairing` already returns true for a local scope-upgrade;
135+
* this is the call-site that honors it (it previously hard-forced `false` for
136+
* every scope-upgrade regardless of locality).
137+
*/
138+
export function resolveDevicePairingSilent(params: {
139+
reason: "not-paired" | "role-upgrade" | "scope-upgrade" | "metadata-upgrade";
140+
allowSilentLocalPairing: boolean;
141+
allowSilentTrustedCidrsNodePairing: boolean;
142+
allowSilentBootstrapPairing: boolean;
143+
}): boolean {
144+
if (params.reason === "scope-upgrade") {
145+
return params.allowSilentLocalPairing;
146+
}
147+
return (
148+
params.allowSilentLocalPairing ||
149+
params.allowSilentTrustedCidrsNodePairing ||
150+
params.allowSilentBootstrapPairing
151+
);
152+
}
153+
120154
function isCliContainerLocalEquivalent(params: {
121155
connectParams: ConnectParams;
122156
requestHost?: string;

src/gateway/server/ws-connection/message-handler.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ import {
163163
shouldSkipControlUiPairing,
164164
} from "./connect-policy.js";
165165
import {
166+
resolveDevicePairingSilent,
166167
resolveDeviceSignaturePayloadVersion,
167168
resolveHandshakeBrowserSecurityContext,
168169
resolvePairingLocality,
@@ -1419,12 +1420,12 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
14191420
scopes: bootstrapPairingScopes ?? [],
14201421
}
14211422
: {}),
1422-
silent:
1423-
reason === "scope-upgrade"
1424-
? false
1425-
: allowSilentLocalPairing ||
1426-
allowSilentTrustedCidrsNodePairing ||
1427-
allowSetupCodeMobileBootstrapPairing,
1423+
silent: resolveDevicePairingSilent({
1424+
reason,
1425+
allowSilentLocalPairing,
1426+
allowSilentTrustedCidrsNodePairing,
1427+
allowSilentBootstrapPairing: allowSetupCodeMobileBootstrapPairing,
1428+
}),
14281429
});
14291430
const context = buildRequestContext();
14301431
let approved: Awaited<ReturnType<typeof approveDevicePairing>> | undefined;

0 commit comments

Comments
 (0)