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}`, }); }