From 7cd5558e4135e27f4294242759a90faf30401e0d Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:54:36 -0600 Subject: [PATCH] fix(authz): send agents denied a cost-recording route to the Costs page (#20922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - When an agent is denied a board-only action, the denial names the board page where a person can do it, and agents relay that link > - Cost recording retry ("Retry cost recording") is board-only and lives on the run's row on the Costs page > - The denial for every `/companies/:id/costs` route named the Budgets page instead > - So an agent asked to fix a run's missing cost sent the person to Budgets, where the control does not exist > - This pull request maps cost routes to the Costs page and names that page in the retry route's catalog entry > - The benefit is that the person lands on the control the agent told them to use ## Linked Issues or Issue Description **What happened** In paid chat eval run 43086418488 (`budgets-costs.repair-cost-recording`), the CEO tried `POST /api/companies/{companyId}/costs/retry-accounting`, got `403 board_access_required` whose remediation said `[Open budgets](//activity/budgets)`, and told the person to use the retry on Budgets. The retry control is on the Costs page. **Expected behavior** Board denials for cost routes name `[Open costs](//activity/costs)`. Budget and budget-incident routes still name Budgets. **Steps to reproduce** 1. As an agent, call `POST /api/companies/{companyId}/costs/retry-accounting`. 2. Read the 403 `remediation`. 3. It links `//activity/budgets`, a page with no cost retry control. ## What Changed - `server/src/routes/authz.ts`: `COMPANY_COSTS_PAGE` (`//activity/costs`); `/companies/:id/costs/*` denials use it. Budgets and budget incidents keep `COMPANY_BUDGETS_PAGE`. - `server/src/routes/openapi.ts`: the retry route's description says a board user retries with Retry cost recording on the run's row on the Costs page. - `board-access-denial.test.ts`: regression row for the retry route (red before the change, green after). ## Verification - `pnpm exec vitest run src/__tests__/board-access-denial.test.ts src/__tests__/openapi-routes.test.ts src/__tests__/agent-refusal-finality.test.ts src/__tests__/cost-data-quality-routes.test.ts src/services/native-runtime/runner-api.test.ts` (server): all pass. The new row fails without the `authz.ts` change. - End-to-end: the chat eval case `budgets-costs.repair-cost-recording` grades that the agent's guidance links the Costs page; it will be rerun after this merges. ## Risks - Low. Only the link text and target in cost-route denials and one catalog description change. Agents that previously relayed Budgets now relay Costs. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- server/src/__tests__/board-access-denial.test.ts | 1 + server/src/routes/authz.ts | 5 ++++- server/src/routes/openapi.ts | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/board-access-denial.test.ts b/server/src/__tests__/board-access-denial.test.ts index 8372b1825f0..34fcf9e4e76 100644 --- a/server/src/__tests__/board-access-denial.test.ts +++ b/server/src/__tests__/board-access-denial.test.ts @@ -35,6 +35,7 @@ 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)"], + ["post", `/api/companies/${COMPANY_ID}/costs/retry-accounting`, "[Open costs](//activity/costs)"], ["put", `/api/companies/${COMPANY_ID}/prompt-injection-prevention`, "[Open connection settings](//company/settings/connections)"], ["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)`], diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index 4862ca61593..833f3554ca9 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -49,6 +49,8 @@ export type BoardControlPage = [label: string, path: string]; /** Budgets are board-only. These pages hold the budget controls behind any route that writes one. */ export const COMPANY_BUDGETS_PAGE: BoardControlPage = ["budgets", "//activity/budgets"]; +/** Cost recording controls (such as retrying a run's saved-usage cost) are on the Costs page, not Budgets. */ +export const COMPANY_COSTS_PAGE: BoardControlPage = ["costs", "//activity/costs"]; export function agentBudgetPage(agentId: string): BoardControlPage { return [`the agent's ${AGENT_PROFILE_TABS.budgets![0]}`, `//agents/${agentId}/${AGENT_PROFILE_TABS.budgets![1]}`]; } @@ -59,7 +61,8 @@ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{ const BOARD_CONTROL_PAGES: Array<[RegExp, (match: RegExpExecArray, req: Request) => [string, string] | null]> = [ [/^\/(?:companies\/[^/]+\/)?secrets?(?:[-/]|$)/, () => ["company secrets", "//company/settings/secrets"]], - [/^\/companies\/[^/]+\/(?:budgets|budget-incidents|costs)(?:\/|$)/, () => COMPANY_BUDGETS_PAGE], + [/^\/companies\/[^/]+\/(?:budgets|budget-incidents)(?:\/|$)/, () => COMPANY_BUDGETS_PAGE], + [/^\/companies\/[^/]+\/costs(?:\/|$)/, () => COMPANY_COSTS_PAGE], [/^\/companies\/[^/]+\/(?:prompt-injection-prevention|decision-model)(?:\/|$)/, () => ["connection settings", "//company/settings/connections"]], [/^\/companies\/[^/]+\/(?:members|invites|join-requests)(?:\/|$)/, () => ["company members", "//company/settings/members"]], [/^\/companies\/[^/]+\/(?:tools\/)?gateways(?:\/|$)|^\/tool-gateway\//, () => ["tool gateways", "//apps/gateways"]], diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index e9e9a8c92c6..5aab5b9265f 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -6123,7 +6123,7 @@ for (const segment of costSummaryPaths) { registry.registerPath({ method: "post", path: "/api/companies/{companyId}/costs/retry-accounting", tags: ["costs"], summary: "Retry recording a run's saved cost data", - description: "Requires company cost-read and board write access. Viewers and agents cannot retry. Replays accounting evidence only; never starts provider work or invents missing usage.", + description: "Requires company cost-read and board write access. Viewers and agents cannot retry. Replays accounting evidence only; never starts provider work or invents missing usage. A board user retries with Retry cost recording on the run's row on the [Costs page](//activity/costs).", request: { params: z.object({ companyId: z.string() }), body: jsonBody(retryAccountingSchema) }, responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, });