From 3c0abb7753e6cf6cf24976fe048d065180bb4c62 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Nov 2026 01:19:48 -0600 Subject: [PATCH] fix(authz): say every plugin management step is board-only in plugin denials An agent asked to finish configuring a plugin first read the plugin list. The board-only denial said that this action was final. The agent then spent 110 seconds searching the API catalog for an agent route to the configuration and secret binding, and its reply came after the chat window closed. A board-only denial on any /api/plugins route now also 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 are unchanged. Co-Authored-By: Claude Opus 5.5 --- server/src/__tests__/board-access-denial.test.ts | 13 +++++++++++++ server/src/routes/authz.ts | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/board-access-denial.test.ts b/server/src/__tests__/board-access-denial.test.ts index b8481f00127..166ddc0653a 100644 --- a/server/src/__tests__/board-access-denial.test.ts +++ b/server/src/__tests__/board-access-denial.test.ts @@ -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) => { diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index e744ceb15d3..e2482253172 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -81,8 +81,17 @@ function pluginControlPage(path: string): [string, string] | null { : ["plugin settings", "//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 .` : "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}`, }); }