From 9af18eba9d98efa8a721e42f51ab24a274b374e9 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 8 Nov 2026 20:37:26 -0600 Subject: [PATCH] fix(authz): link the agent profile tab that holds a denied control Address review: a denied POST /api/agents/{id}/keys linked the bare profile, but API keys are only reachable at /api-keys. Map the agent subresources with their own tab (keys, permissions, budgets, identity and instructions, configuration) to that tab. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/board-access-denial.test.ts | 4 +++- server/src/routes/authz.ts | 17 ++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/board-access-denial.test.ts b/server/src/__tests__/board-access-denial.test.ts index e217af86064..991a9f30561 100644 --- a/server/src/__tests__/board-access-denial.test.ts +++ b/server/src/__tests__/board-access-denial.test.ts @@ -35,7 +35,9 @@ describe("board-only denial", () => { ["post", `/api/companies/${COMPANY_ID}/secrets`, "[Open company secrets](//company/settings/secrets)"], ["post", `/api/secrets/${AGENT_ID}/rotate`, "[Open company secrets](//company/settings/secrets)"], ["post", `/api/companies/${COMPANY_ID}/budget-incidents/i-1/resolve?x=1`, "[Open budgets](//activity/budgets)"], - ["patch", `/api/agents/${AGENT_ID}/permissions`, `[Open the agent's profile](//agents/${AGENT_ID})`], + ["patch", `/api/agents/${AGENT_ID}/permissions`, `[Open the agent's permissions](//agents/${AGENT_ID}/permissions)`], + ["post", `/api/agents/${AGENT_ID}/keys`, `[Open the agent's API keys](//agents/${AGENT_ID}/api-keys)`], + ["post", `/api/agents/${AGENT_ID}/pause`, `[Open the agent's profile](//agents/${AGENT_ID})`], ] as const)("tells an agent %s %s is final and names the board page", async (method, path, link) => { const res = await request(createApp(agentActor))[method](path).expect(403); expect(res.body.error).toBe("Board access required"); diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index 0d65dc66536..27709a3b8c0 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -31,6 +31,18 @@ export function assertAuthenticated(req: Request) { export const BOARD_ACCESS_REQUIRED_CODE = "board_access_required"; +// Agent API subresources whose controls live on their own profile tab. +const AGENT_PROFILE_TABS: Record = { + keys: ["API keys", "api-keys"], + permissions: ["permissions", "permissions"], + budgets: ["budget", "budgets"], + identity: ["identity and instructions", "identity"], + "instructions-bundle": ["identity and instructions", "identity"], + "instructions-path": ["identity and instructions", "identity"], + configuration: ["harness settings", "runtime"], + "runtime-state": ["harness settings", "runtime"], +}; + // The board page that holds the control behind a board-only API route, when one // is known. Paths keep for the agent to fill from its own task identifier. const BOARD_CONTROL_PAGES: Array<[RegExp, (match: RegExpExecArray) => [string, string]]> = [ @@ -39,7 +51,10 @@ const BOARD_CONTROL_PAGES: Array<[RegExp, (match: RegExpExecArray) => [string, s [/^\/companies\/[^/]+\/(?:members|invites|join-requests)(?:\/|$)/, () => ["company members", "//company/settings/members"]], [/^\/companies\/[^/]+\/(?:tools\/)?gateways(?:\/|$)|^\/tool-gateway\//, () => ["tool gateways", "//apps/gateways"]], [/^\/approvals\/([^/]+)/, match => ["the approval", `//approvals/${match[1]}`]], - [/^\/agents\/([0-9a-f-]{36})(?:\/|$)/i, match => ["the agent's profile", `//agents/${match[1]}`]], + [/^\/agents\/([0-9a-f-]{36})(?:\/([^/]+))?(?:\/|$)/i, match => { + const tab = match[2] && Object.hasOwn(AGENT_PROFILE_TABS, match[2]) ? AGENT_PROFILE_TABS[match[2]] : undefined; + return tab ? [`the agent's ${tab[0]}`, `//agents/${match[1]}/${tab[1]}`] : ["the agent's profile", `//agents/${match[1]}`]; + }], [/^\/companies\/[^/]+(?:\/(?:archive|branding|settings))?\/?$/, () => ["company settings", "//company/settings"]], ];