From 0089d0e2e64e4cac3dc90f5e01ae50abb4de3c9b Mon Sep 17 00:00:00 2001 From: Agustin Rivera <31522568+eleqtrizit@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:06:09 +0000 Subject: [PATCH] fix(pairing): require pairing scope for node approvals --- src/agents/tools/nodes-tool.test.ts | 12 ++++++------ src/agents/tools/nodes-tool.ts | 6 +++--- src/gateway/method-scopes.test.ts | 10 ++++++++-- src/gateway/method-scopes.ts | 2 +- src/gateway/server.node-pairing-authz.test.ts | 8 ++++---- 5 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/agents/tools/nodes-tool.test.ts b/src/agents/tools/nodes-tool.test.ts index 26156834d73..213c53d2d86 100644 --- a/src/agents/tools/nodes-tool.test.ts +++ b/src/agents/tools/nodes-tool.test.ts @@ -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"] }, ); }); diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index 1877c7b4c9e..ace7c328b6c 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -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( diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 658a9dad902..f49d8650d08 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -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", }); }); diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 409d76bc19a..da7fe5d7cab 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -45,6 +45,7 @@ const METHOD_SCOPE_GROUPS: Record = { "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 = { "tts.setProvider", "voicewake.set", "node.invoke", - "node.pair.approve", "chat.send", "chat.abort", "sessions.create", diff --git a/src/gateway/server.node-pairing-authz.test.ts b/src/gateway/server.node-pairing-authz.test.ts index 395a83a8d28..bb4b73b217d 100644 --- a/src/gateway/server.node-pairing-authz.test.ts +++ b/src/gateway/server.node-pairing-authz.test.ts @@ -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")),