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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-09 00:12:15 -06:00
1 parent 6d04feb54e
commit 7c7de99480
2 files changed
+33

No files matched your search

@@ -41,6 +41,10 @@ describe("board-only denial", () => {
["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)`],
["get", "/api/plugins", "[Open plugin settings](/<prefix>/company/settings/instance/plugins)"],
["post", "/api/plugins/install", "[Open plugin settings](/<prefix>/company/settings/instance/plugins)"],
["post", `/api/plugins/${AGENT_ID}/config`, `[Open the plugin's settings](/<prefix>/company/settings/instance/plugins/${AGENT_ID})`],
["post", "/api/plugins/acme.website-preview/disable", "[Open the plugin's settings](/<prefix>/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);
+16
View File
@@ -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", "/<prefix>/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", `/<prefix>/company/settings/instance/plugins/${plugin}`]
: ["plugin settings", "/<prefix>/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);