mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
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:
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user