From 7c7de9948008347289c126a9ce2372c768c8cb71 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 8 Nov 2026 23:05:59 -0600 Subject: [PATCH] fix(authz): link plugin settings when an agent hits a board-only plugin route Board-only plugin routes (list, install, config, enable/disable) gave agents the generic "link a page only if you already know its route" remediation, so a chat agent asked to configure a plugin kept searching the API for another route instead of handing the step to the person. Name the plugin manager, or that plugin's settings page when the route names a plugin id or key, unless the operator hides plugin management. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/board-access-denial.test.ts | 17 +++++++++++++++++ server/src/routes/authz.ts | 16 ++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/server/src/__tests__/board-access-denial.test.ts b/server/src/__tests__/board-access-denial.test.ts index 1f15a329bae..d8217041624 100644 --- a/server/src/__tests__/board-access-denial.test.ts +++ b/server/src/__tests__/board-access-denial.test.ts @@ -41,6 +41,10 @@ describe("board-only denial", () => { ["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)`], + ["get", "/api/plugins", "[Open plugin settings](//company/settings/instance/plugins)"], + ["post", "/api/plugins/install", "[Open plugin settings](//company/settings/instance/plugins)"], + ["post", `/api/plugins/${AGENT_ID}/config`, `[Open the plugin's settings](//company/settings/instance/plugins/${AGENT_ID})`], + ["post", "/api/plugins/acme.website-preview/disable", "[Open the plugin's settings](//company/settings/instance/plugins/acme.website-preview)"], ] 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"); @@ -64,6 +68,19 @@ describe("board-only denial", () => { expect(res.body.remediation).not.toContain("/company/settings/connections"); }); + it("does not link plugin settings while the operator hides plugin management", async () => { + const previous = process.env.PAPERCLIP_HIDDEN_SETTINGS; + process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.plugins"; + try { + const res = await request(createApp(agentActor)).post(`/api/plugins/${AGENT_ID}/config`).expect(403); + expect(res.body.remediation).toContain("this denial is final"); + expect(res.body.remediation).not.toContain("/instance/plugins"); + } finally { + if (previous === undefined) delete process.env.PAPERCLIP_HIDDEN_SETTINGS; + else process.env.PAPERCLIP_HIDDEN_SETTINGS = previous; + } + }); + it("applies through the org-access guard", async () => { const res = await request(createApp(agentActor, assertBoardOrgAccess)).delete(`/api/companies/${COMPANY_ID}`).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 271bd8cceb5..9b16388328e 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -3,6 +3,7 @@ import type { SecretBindingTargetType } from "@paperclipai/shared"; import { forbidden, HttpError, unauthorized } from "../errors.js"; import { logger } from "../middleware/logger.js"; import { responsibleUserAuthzShadowMode } from "../services/authorization.js"; +import { getHiddenSettings } from "../services/settings-visibility.js"; function throwOrShadowResponsibleUserCompanyAccessDeny( req: Request, @@ -61,8 +62,23 @@ const BOARD_CONTROL_PAGES: Array<[RegExp, (match: RegExpExecArray) => [string, s [/^\/companies\/[^/]+(?:\/(?:archive|branding|settings))?\/?$/, () => ["company settings", "//company/settings"]], ]; +// Plugin management pages. A plugin id or key segment opens that plugin's settings +// (the page accepts either); any other segment (install, tools) opens the manager. +// An operator can hide plugin management, so link it only while it is visible. +const PLUGIN_REFERENCE = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[a-z0-9][a-z0-9_-]*(?:\.[a-z0-9_-]+)+)$/i; +function pluginControlPage(path: string): [string, string] | null { + const match = /^\/plugins(?:\/([^/]+))?(?:\/|$)/.exec(path); + if (!match || getHiddenSettings().has("instance.plugins")) return null; + const plugin = match[1] && PLUGIN_REFERENCE.test(match[1]) ? match[1] : null; + return plugin + ? ["the plugin's settings", `//company/settings/instance/plugins/${plugin}`] + : ["plugin settings", "//company/settings/instance/plugins"]; +} + function boardControlPage(req: Request) { const path = (req.originalUrl ?? "").split("?")[0]!.replace(/^\/api(?=\/)/, ""); + const pluginPage = pluginControlPage(path); + if (pluginPage) return pluginPage; for (const [pattern, page] of BOARD_CONTROL_PAGES) { const match = pattern.exec(path); if (match) return page(match);