fix(pairing): require pairing scope for node approvals

This commit is contained in:
Agustin Rivera 2026-04-03 18:06:09 +00:00 committed by Peter Steinberger
parent a90f3ffdac
commit 0089d0e2e6
5 changed files with 22 additions and 16 deletions

View File

@ -211,7 +211,7 @@ describe("createNodesTool screen_record duration guardrails", () => {
expect(JSON.stringify(result?.content ?? [])).not.toContain("MEDIA:");
});
it("uses operator.admin to approve exec-capable node pair requests", async () => {
it("uses operator.pairing plus operator.admin to approve exec-capable node pair requests", async () => {
gatewayMocks.callGatewayTool.mockImplementation(async (method, _opts, params, extra) => {
if (method === "node.pair.list") {
return {
@ -247,11 +247,11 @@ describe("createNodesTool screen_record duration guardrails", () => {
"node.pair.approve",
{},
{ requestId: "req-1" },
{ scopes: ["operator.admin"] },
{ scopes: ["operator.pairing", "operator.admin"] },
);
});
it("uses operator.write to approve non-exec node pair requests", async () => {
it("uses operator.pairing plus operator.write to approve non-exec node pair requests", async () => {
gatewayMocks.callGatewayTool.mockImplementation(async (method, _opts, params, extra) => {
if (method === "node.pair.list") {
return {
@ -287,11 +287,11 @@ describe("createNodesTool screen_record duration guardrails", () => {
"node.pair.approve",
{},
{ requestId: "req-1" },
{ scopes: ["operator.write"] },
{ scopes: ["operator.pairing", "operator.write"] },
);
});
it("uses operator.write for commandless node pair requests", async () => {
it("uses operator.pairing for commandless node pair requests", async () => {
gatewayMocks.callGatewayTool.mockImplementation(async (method, _opts, params, extra) => {
if (method === "node.pair.list") {
return {
@ -319,7 +319,7 @@ describe("createNodesTool screen_record duration guardrails", () => {
"node.pair.approve",
{},
{ requestId: "req-1" },
{ scopes: ["operator.write"] },
{ scopes: ["operator.pairing"] },
);
});

View File

@ -49,12 +49,12 @@ function resolveApproveScopes(commands: unknown): OperatorScope[] {
if (
normalized.some((command) => NODE_SYSTEM_RUN_COMMANDS.some((allowed) => allowed === command))
) {
return ["operator.admin"];
return ["operator.pairing", "operator.admin"];
}
if (normalized.length > 0) {
return ["operator.write"];
return ["operator.pairing", "operator.write"];
}
return ["operator.write"];
return ["operator.pairing"];
}
async function resolveNodePairApproveScopes(

View File

@ -22,7 +22,7 @@ describe("method scope resolution", () => {
["sessions.abort", ["operator.write"]],
["sessions.messages.subscribe", ["operator.read"]],
["sessions.messages.unsubscribe", ["operator.read"]],
["node.pair.approve", ["operator.write"]],
["node.pair.approve", ["operator.pairing"]],
["poll", ["operator.write"]],
["config.patch", ["operator.admin"]],
["wizard.start", ["operator.admin"]],
@ -67,9 +67,15 @@ describe("operator scope authorization", () => {
allowed: false,
missingScope: "operator.write",
});
});
it("requires pairing scope for node pairing approvals", () => {
expect(authorizeOperatorScopesForMethod("node.pair.approve", ["operator.pairing"])).toEqual({
allowed: true,
});
expect(authorizeOperatorScopesForMethod("node.pair.approve", ["operator.write"])).toEqual({
allowed: false,
missingScope: "operator.write",
missingScope: "operator.pairing",
});
});

View File

@ -45,6 +45,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
"node.pair.list",
"node.pair.reject",
"node.pair.verify",
"node.pair.approve",
"device.pair.list",
"device.pair.approve",
"device.pair.reject",
@ -112,7 +113,6 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
"tts.setProvider",
"voicewake.set",
"node.invoke",
"node.pair.approve",
"chat.send",
"chat.abort",
"sessions.create",

View File

@ -40,7 +40,7 @@ async function connectNodeClient(params: {
}
describe("gateway node pairing authorization", () => {
test("requires operator.write before node pairing approvals", async () => {
test("requires operator.admin for exec-capable node pairing approvals", async () => {
const started = await startServerWithClient("secret");
const approver = await issueOperatorToken({
name: "node-pair-approve-pairing-only",
@ -70,7 +70,7 @@ describe("gateway node pairing authorization", () => {
requestId: request.request.requestId,
});
expect(approve.ok).toBe(false);
expect(approve.error?.message).toBe("missing scope: operator.write");
expect(approve.error?.message).toBe("missing scope: operator.admin");
await expect(
import("../infra/node-pairing.js").then((m) => m.getPairedNode("node-approve-target")),
@ -83,7 +83,7 @@ describe("gateway node pairing authorization", () => {
}
});
test("rejects approving exec-capable node commands above the caller session scopes", async () => {
test("requires operator.pairing before node pairing approvals", async () => {
const started = await startServerWithClient("secret");
const approver = await issueOperatorToken({
name: "node-pair-approve-attacker",
@ -113,7 +113,7 @@ describe("gateway node pairing authorization", () => {
requestId: request.request.requestId,
});
expect(approve.ok).toBe(false);
expect(approve.error?.message).toBe("missing scope: operator.admin");
expect(approve.error?.message).toBe("missing scope: operator.pairing");
await expect(
import("../infra/node-pairing.js").then((m) => m.getPairedNode("node-approve-target")),