fix(authz): link the project's access settings when an agent cannot share a project (#20176)

## 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
(`/<prefix>/projects/<id>/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 `/<prefix>/projects/<id>/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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-09 00:11:08 -06:00
1 parent 2fc0293586
commit b07399c241
2 files changed
+34 -2

No files matched your search

@@ -39,6 +39,8 @@ describe("board-only denial", () => {
["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})`],
["post", `/api/projects/${AGENT_ID}/access-members`, `[Open the project's access settings](/<prefix>/projects/${AGENT_ID}/configuration)`],
["delete", `/api/projects/${AGENT_ID}/access-members/m-1`, `[Open the project's access settings](/<prefix>/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](/<prefix>/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);
+8 -2
View File
@@ -45,13 +45,19 @@ const AGENT_PROFILE_TABS: Record<string, [string, string]> = {
// 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]]> = [
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", "/<prefix>/company/settings/secrets"]],
[/^\/companies\/[^/]+\/(?:budgets|budget-incidents|costs)(?:\/|$)/, () => ["budgets", "/<prefix>/activity/budgets"]],
[/^\/companies\/[^/]+\/(?:prompt-injection-prevention|decision-model)(?:\/|$)/, () => ["connection settings", "/<prefix>/company/settings/connections"]],
[/^\/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]}`]],
[/^\/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", `/<prefix>/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]}`, `/<prefix>/agents/${match[1]}/${tab[1]}`] : ["the agent's profile", `/<prefix>/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;
}