From b07399c241f5abe7817340d4ba4554198f840d70 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:11:08 -0600 Subject: [PATCH] fix(authz): link the project's access settings when an agent cannot share a project (#20176) 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 > - Some actions are board-only. When an agent calls one, the 403 denial tells it that the denial is final and, when known, links the board page that holds the control > - Project sharing (`/api/projects/:id/access-members`) had no known page, so the denial told agents to link a page only if they already knew its route > - An agent asked to "Let Riley see the acquisition project" got this denial. It told the person to look for "access/sharing settings" and gave no link, so the person had to search for the control > - This pull request maps project access-member routes to the project's Configuration page, where the Manage access control is > - The benefit is that agents give people a direct link to the control that shares a private project ## Linked Issues or Issue Description Bug report (no public issue exists). **What happened** An agent tried to add a person to a private project. `POST /api/projects/:id/access-members` returned 403 "Board access required" without a page link. The agent told the person to find the project's access or sharing settings in the web app and gave no link. **Expected behavior** The denial links the project's Configuration page (`//projects//configuration`), where Manage access is. The agent gives that link to the person. **Steps to reproduce** 1. Create a private project and give an agent access to it. 2. In chat, ask the agent to let another company member see the project. 3. The agent's access-member request is denied, and its reply has no link to the sharing control. Refs #20151 ## What Changed - `server/src/routes/authz.ts`: board-only denials for `/projects/:id/access-members` (and its member subpaths) link "the project's access settings" at `//projects//configuration`. The link uses the resolved route project ID, so short references such as `/api/projects/acquisition/access-members` also get the link. Without a resolved UUID, the denial falls back to the generic instruction. - `server/src/__tests__/board-access-denial.test.ts`: cases for add and remove access-member denials, plus a short-reference case that goes through a route param resolver. ## Verification - `cd server && npx vitest run src/__tests__/board-access-denial.test.ts` passes (17 tests). The short-reference test fails on the previous matcher. The server `tsc --noEmit` passes. - The chat eval `projects.share-project` found this gap: in one run the agent linked the project, and in the next run it gave no link. ## Risks - Low risk. This changes only the remediation text in an agent's 403 response. It does not change authorization. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code execution, used through 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 --- .../src/__tests__/board-access-denial.test.ts | 26 +++++++++++++++++++ server/src/routes/authz.ts | 10 +++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/board-access-denial.test.ts b/server/src/__tests__/board-access-denial.test.ts index 582cc11e6c4..827a085e12c 100644 --- a/server/src/__tests__/board-access-denial.test.ts +++ b/server/src/__tests__/board-access-denial.test.ts @@ -39,6 +39,8 @@ describe("board-only denial", () => { ["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})`], + ["post", `/api/projects/${AGENT_ID}/access-members`, `[Open the project's access settings](//projects/${AGENT_ID}/configuration)`], + ["delete", `/api/projects/${AGENT_ID}/access-members/m-1`, `[Open the project's access settings](//projects/${AGENT_ID}/configuration)`], ] 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"); @@ -48,6 +50,30 @@ describe("board-only denial", () => { expect(res.body.remediation).toContain(link); }); + it("links the resolved project when an agent names the project by its short reference", async () => { + const app = express(); + app.use((req, _res, next) => { + req.actor = agentActor; + next(); + }); + const router = express.Router(); + router.param("id", (req, _res, next, rawId) => { + req.params.id = rawId === "acquisition" ? AGENT_ID : rawId; + next(); + }); + router.post("/projects/:id/access-members", (req, res) => { + assertBoard(req); + res.json({ ok: true }); + }); + app.use("/api", router); + app.use(errorHandler); + const resolved = await request(app).post("/api/projects/acquisition/access-members").expect(403); + expect(resolved.body.remediation).toContain(`[Open the project's access settings](//projects/${AGENT_ID}/configuration)`); + const unresolved = await request(app).post("/api/projects/unknown-project/access-members").expect(403); + expect(unresolved.body.remediation).toContain("Link a page only if you already know its route"); + expect(unresolved.body.remediation).not.toContain("](/"); + }); + it("falls back to a general board instruction when no page is known", async () => { const res = await request(createApp(agentActor)).get(`/api/companies/${COMPANY_ID}/managed-agent-profiles`).expect(403); expect(res.body.code).toBe(BOARD_ACCESS_REQUIRED_CODE); diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index 10d2400fd55..c6c60559a03 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -45,13 +45,19 @@ const AGENT_PROFILE_TABS: Record = { // 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]]> = [ +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +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)(?:\/|$)/, () => ["budgets", "//activity/budgets"]], [/^\/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"]], [/^\/approvals\/([^/]+)/, match => ["the approval", `//approvals/${match[1]}`]], + [/^\/projects\/([^/]+)\/access-members(?:\/|$)/, (match, req) => { + const projectId = [req.params?.id, match[1]].find((id): id is string => typeof id === "string" && UUID_PATTERN.test(id)); + return projectId ? ["the project's access settings", `//projects/${projectId}/configuration`] : null; + }], [/^\/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]}`]; @@ -63,7 +69,7 @@ function boardControlPage(req: Request) { const path = (req.originalUrl ?? "").split("?")[0]!.replace(/^\/api(?=\/)/, ""); for (const [pattern, page] of BOARD_CONTROL_PAGES) { const match = pattern.exec(path); - if (match) return page(match); + if (match) return page(match, req); } return null; }