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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-08 21:17:21 -06:00
1 parent 41399e317a
commit 9af18eba9d
2 files changed
+19 -2

No files matched your search

@@ -35,7 +35,9 @@ describe("board-only denial", () => {
["post", `/api/companies/${COMPANY_ID}/secrets`, "[Open company secrets](/<prefix>/company/settings/secrets)"],
["post", `/api/secrets/${AGENT_ID}/rotate`, "[Open company secrets](/<prefix>/company/settings/secrets)"],
["post", `/api/companies/${COMPANY_ID}/budget-incidents/i-1/resolve?x=1`, "[Open budgets](/<prefix>/activity/budgets)"],
["patch", `/api/agents/${AGENT_ID}/permissions`, `[Open the agent's profile](/<prefix>/agents/${AGENT_ID})`],
["patch", `/api/agents/${AGENT_ID}/permissions`, `[Open the agent's permissions](/<prefix>/agents/${AGENT_ID}/permissions)`],
["post", `/api/agents/${AGENT_ID}/keys`, `[Open the agent's API keys](/<prefix>/agents/${AGENT_ID}/api-keys)`],
["post", `/api/agents/${AGENT_ID}/pause`, `[Open the agent's profile](/<prefix>/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");
+16 -1
View File
@@ -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<string, [string, string]> = {
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 <prefix> 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", "/<prefix>/company/settings/members"]],
[/^\/companies\/[^/]+\/(?:tools\/)?gateways(?:\/|$)|^\/tool-gateway\//, () => ["tool gateways", "/<prefix>/apps/gateways"]],
[/^\/approvals\/([^/]+)/, match => ["the approval", `/<prefix>/approvals/${match[1]}`]],
[/^\/agents\/([0-9a-f-]{36})(?:\/|$)/i, match => ["the agent's profile", `/<prefix>/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]}`, `/<prefix>/agents/${match[1]}/${tab[1]}`] : ["the agent's profile", `/<prefix>/agents/${match[1]}`];
}],
[/^\/companies\/[^/]+(?:\/(?:archive|branding|settings))?\/?$/, () => ["company settings", "/<prefix>/company/settings"]],
];