fix(authz): say every plugin management step is board-only in plugin denials (#20207)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - People ask a chat agent to do work, and some of that work is
board-only, such as plugin management
> - When an agent gets a board-only denial, the denial tells it to stop
and reply with a link to the board page
> - The denial only spoke about the one route that was denied. An agent
that read the plugin list first was not told that plugin configuration
and secret binding are also board-only
> - The agent then searched the API catalog for an agent route to
configure the plugin. It searched for 110 seconds, and its reply came
after the chat window closed
> - This pull request adds one fact to every board-only denial on a
plugin route: all plugin management steps have no agent route
> - The benefit is that the agent replies at once with the plugin
settings link, and does not search for a route that does not exist

## Linked Issues or Issue Description

Refs #20182 (the plugin denial link and "reply now" text that this
builds on).

**What happened**

In the chat eval `plugins.configure-plugin`, the person asked the agent
to finish configuring the preview plugin. The agent called `GET
/api/plugins` and got the board-only denial. Its reasoning said: "Denial
final. But ... perhaps the preview plugin is configured through a
different route accessible to agents". It then made 14 more catalog
searches for plugin config, credential and secret-binding routes. The
run did not reply before the 150 second chat window closed.

**Expected behavior**

After a board-only denial on any plugin route, the agent replies now. It
says that a board user must configure the plugin, and it links the
plugin settings page.

**Steps to reproduce**

1. Install a plugin that has a required secret setting, and do not
configure it.
2. In chat, ask an agent: "Finish configuring the preview plugin."
3. The agent calls `GET /api/plugins` and gets 403 "Board access
required".
4. Before this change, the denial does not say that configuration and
secret binding are also board-only, and the agent searches for another
route.

## What Changed

- `server/src/routes/authz.ts`: a board-only denial for an agent on any
`/api/plugins` route adds one sentence. The sentence says that listing,
installing, configuring (including choosing a secret for a plugin
setting), enabling, disabling, upgrading and removing plugins have no
agent route.
- Other board-only denials do not change. Plugin tools that are assigned
to an agent are not mentioned, so tool use is not discouraged.
- `server/src/__tests__/board-access-denial.test.ts`: a regression test
for the plugin list and plugin config denials, and a negative check for
a non-plugin denial.

## Verification

- `cd server && npx vitest run
src/__tests__/board-access-denial.test.ts` passes (26 tests).
- Without the `authz.ts` change, the new test fails.
- The chat eval `plugins.configure-plugin` is rerun on a case branch
that includes this commit. The result is in the PR comments.

## Risks

- Low risk. The change only adds text to the 403 body that agents get.
People and board actors get the same body as before.
- The sentence is a fact about the current routes. Every plugin
management route uses `assertBoard`, `assertBoardOrgAccess` or
`assertInstanceAdmin`. If a plugin management route becomes
agent-accessible later, this sentence must change too.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with 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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-09 01:41:57 -06:00
1 parent 6fd46c0025
commit 09e24dd12c
2 files changed
+25 -2

No files matched your search

@@ -58,6 +58,19 @@ describe("board-only denial", () => {
expect(res.body.remediation).toContain(link);
});
it("tells an agent denied any plugin route that every plugin management step is board-only", async () => {
// An agent asked to finish configuring a plugin usually reads the plugin list first.
// That denial must also settle configuration and secret binding, or the agent keeps
// searching the catalog for an agent route to them.
for (const path of ["/api/plugins", `/api/plugins/${AGENT_ID}/config`]) {
const res = await request(createApp(agentActor)).get(path).expect(403);
expect(res.body.remediation).toContain("Plugin management is board-only throughout");
expect(res.body.remediation).toContain("configuring (including choosing a secret for a plugin setting)");
}
const other = await request(createApp(agentActor)).post(`/api/companies/${COMPANY_ID}/secrets`).expect(403);
expect(other.body.remediation).not.toContain("Plugin management");
});
it("links the resolved project when an agent names the project by its short reference", async () => {
const app = express();
app.use((req, _res, next) => {
+12 -2
View File
@@ -81,8 +81,17 @@ function pluginControlPage(path: string): [string, string] | null {
: ["plugin settings", "/<prefix>/company/settings/instance/plugins"];
}
// Every plugin management route is board-only, so a denial on any one of them
// (often the list an agent reads first) also answers the plugin's other management steps.
const PLUGIN_ROUTE = /^\/plugins(?:\/|$)/;
const PLUGIN_ADMINISTRATION_SCOPE = "Plugin management is board-only throughout: listing, installing, configuring (including choosing a secret for a plugin setting), enabling, disabling, upgrading and removing plugins have no agent route either.";
function deniedApiPath(req: Request) {
return (req.originalUrl ?? "").split("?")[0]!.replace(/^\/api(?=\/)/, "");
}
function boardControlPage(req: Request) {
const path = (req.originalUrl ?? "").split("?")[0]!.replace(/^\/api(?=\/)/, "");
const path = deniedApiPath(req);
const pluginPage = pluginControlPage(path);
if (pluginPage) return pluginPage;
for (const [pattern, page] of BOARD_CONTROL_PAGES) {
@@ -99,12 +108,13 @@ function boardControlPage(req: Request) {
export function boardAccessRequired(req: Request) {
if (req.actor.type !== "agent") return forbidden("Board access required");
const page = boardControlPage(req);
const scope = PLUGIN_ROUTE.test(deniedApiPath(req)) ? `${PLUGIN_ADMINISTRATION_SCOPE} ` : "";
const where = page
? `They can do it in the Paperclip web app: link [Open ${page[0]}](${page[1]}), with this conversation's company prefix as <prefix>.`
: "They can do it in the Paperclip web app or with their own board credentials. Link a page only if you already know its route; do not search for one.";
return forbidden("Board access required", {
code: BOARD_ACCESS_REQUIRED_CODE,
remediation: `This action is board-only by design and this denial is final: no agent credential, tool or other API route can do it, so do not search for one. Stop working on this step and reply now: tell the person that a board user must do it. ${where}`,
remediation: `This action is board-only by design and this denial is final: no agent credential, tool or other API route can do it, so do not search for one. Stop working on this step and reply now: tell the person that a board user must do it. ${scope}${where}`,
});
}