mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
fix(authz): name the Experimental settings page when an agent is denied an instance experiment
An agent asked to turn on an instance experiment (for example Cases) reads or patches /api/instance/settings/experimental and gets the generic board-only denial, which names no page. Turning Cases off also denies the case API with a bare "Cases are disabled". In both cases the agent cannot tell the person where to act, so it cannot give a usable next step. - The board-only denial for the experimental settings route now links [Open Experimental settings](/<prefix>/company/settings/instance/experimental), unless the operator hides that page. - The case API gate keeps its message and adds a final remediation that names the same page (guarded the same way). - The API catalog entries for the experimental settings route name the page, say agents cannot read or change it, and give Cases/enableCases as an example. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ffe05b1b7c
commit
e2b337b330
6 files changed
+51
-4
No files matched your search
@@ -48,6 +48,8 @@ describe("board-only denial", () => {
|
||||
["get", "/api/plugins/ui-contributions", "[Open plugin settings](/<prefix>/company/settings/instance/plugins)"],
|
||||
["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/instance/settings/experimental", "[Open Experimental settings](/<prefix>/company/settings/instance/experimental)"],
|
||||
["patch", "/api/instance/settings/experimental", "[Open Experimental settings](/<prefix>/company/settings/instance/experimental)"],
|
||||
] 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");
|
||||
@@ -122,6 +124,19 @@ describe("board-only denial", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not link Experimental settings while the operator hides that page", async () => {
|
||||
const previous = process.env.PAPERCLIP_HIDDEN_SETTINGS;
|
||||
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.experimental";
|
||||
try {
|
||||
const res = await request(createApp(agentActor)).patch("/api/instance/settings/experimental").expect(403);
|
||||
expect(res.body.remediation).toContain("this denial is final");
|
||||
expect(res.body.remediation).not.toContain("/instance/experimental");
|
||||
} 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);
|
||||
|
||||
@@ -202,7 +202,11 @@ describeEmbeddedPostgres("cases routes", () => {
|
||||
}).returning();
|
||||
const http = request(app(boardActor));
|
||||
|
||||
await http.get(`/api/companies/${company.id}/cases`).expect(403);
|
||||
const denied = await http.get(`/api/companies/${company.id}/cases`).expect(403);
|
||||
// The denial is final and names the page where an instance admin turns Cases on.
|
||||
expect(denied.body).toMatchObject({ error: "Cases are disabled", code: "cases_disabled" });
|
||||
expect(denied.body.remediation).toContain("[Experimental settings](/<prefix>/company/settings/instance/experimental)");
|
||||
expect(denied.body.remediation).toContain("Agent credentials cannot change that setting");
|
||||
await http.get(`/api/companies/${company.id}/cases/types`).expect(403);
|
||||
await http.post(`/api/companies/${company.id}/cases`).send({ caseType: "bug", title: "Bug" }).expect(403);
|
||||
await http.get(`/api/cases/${caseRow!.id}`).expect(403);
|
||||
|
||||
@@ -653,6 +653,16 @@ describe("openapi routes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("names the Experimental settings page where an instance admin turns a feature on", async () => {
|
||||
const res = await request(createApp()).get("/api/openapi.json");
|
||||
const experimental = res.body.paths["/api/instance/settings/experimental"];
|
||||
for (const operation of [experimental.get, experimental.patch]) {
|
||||
expect(operation.description).toContain("[Experimental settings](/<prefix>/company/settings/instance/experimental)");
|
||||
expect(operation.description).toContain("Agent credentials cannot read or change these settings");
|
||||
expect(operation.description).toContain("Cases via enableCases");
|
||||
}
|
||||
});
|
||||
|
||||
it("tells agents that archiving keeps a status card and where a person restores it", async () => {
|
||||
const res = await request(createApp()).get("/api/openapi.json");
|
||||
const patch = res.body.paths["/api/status-cards/{id}"].patch;
|
||||
|
||||
@@ -73,6 +73,9 @@ const BOARD_CONTROL_PAGES: Array<[RegExp, (match: RegExpExecArray, req: Request)
|
||||
return tab ? [`the agent's ${tab[0]}`, `/<prefix>/agents/${match[1]}/${tab[1]}`] : ["the agent's profile", `/<prefix>/agents/${match[1]}`];
|
||||
}],
|
||||
[/^\/companies\/[^/]+(?:\/(?:archive|branding|settings))?\/?$/, () => ["company settings", "/<prefix>/company/settings"]],
|
||||
// An operator can hide the Experimental page, so link it only while it is visible.
|
||||
[/^\/instance\/settings\/experimental\/?$/, () => getHiddenSettings().has("instance.experimental")
|
||||
? null : ["Experimental settings", "/<prefix>/company/settings/instance/experimental"]],
|
||||
];
|
||||
|
||||
// Plugin management pages. A plugin id or key segment opens that plugin's settings
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
} from "@paperclipai/shared";
|
||||
import { formatAttachmentSize, MAX_ATTACHMENT_BYTES, normalizeContentType } from "../attachment-types.js";
|
||||
import { HttpError, badRequest, conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { getHiddenSettings } from "../services/settings-visibility.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { documentAnnotationService, logActivity } from "../services/index.js";
|
||||
@@ -80,7 +81,16 @@ function eventActorValues(actor: CaseActor) {
|
||||
async function assertCasesEnabled(db: Db) {
|
||||
const experimental = await instanceSettingsService(db).getExperimental();
|
||||
if (!experimental.enableCases) {
|
||||
throw forbidden("Cases are disabled");
|
||||
// An operator can hide the Experimental page, so link it only while it is visible.
|
||||
const where = getHiddenSettings().has("instance.experimental")
|
||||
? "An instance operator controls it."
|
||||
: "An instance admin turns them on with Cases in [Experimental settings](/<prefix>/company/settings/instance/experimental), "
|
||||
+ "with this conversation's company prefix as <prefix>.";
|
||||
throw forbidden("Cases are disabled", {
|
||||
code: "cases_disabled",
|
||||
remediation: "Cases are an experimental feature that is turned off on this instance, so this denial is final and no other route reaches cases. "
|
||||
+ `${where} Agent credentials cannot change that setting. Reply now.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7261,6 +7261,11 @@ registry.registerPath({
|
||||
|
||||
// ─── Instance settings ────────────────────────────────────────────────────────
|
||||
|
||||
// Agents cannot read or write experimental settings; the person turns a feature on in the UI.
|
||||
const EXPERIMENTAL_SETTINGS_PAGE = "Instance experimental features (for example Cases via enableCases) are turned on or off by an instance admin in "
|
||||
+ "Experimental settings at /<prefix>/company/settings/instance/experimental. Agent credentials cannot read or change these settings, "
|
||||
+ "so do not claim a change: link that page as [Experimental settings](/<prefix>/company/settings/instance/experimental), name the feature's setting, and reply.";
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/instance/settings",
|
||||
@@ -7300,7 +7305,7 @@ registry.registerPath({
|
||||
path: "/api/instance/settings/experimental",
|
||||
tags: ["instance"],
|
||||
summary: "Get experimental instance settings",
|
||||
description: "Agent Chat is always on for every person on every instance and needs no setup. enableAgentChat is a retired compatibility key: it always reads true and written values are ignored. If someone asks to enable Agent Chat, tell them it is already on, since they are using it now; do not send them to a settings toggle.",
|
||||
description: `${EXPERIMENTAL_SETTINGS_PAGE} Agent Chat is always on for every person on every instance and needs no setup. enableAgentChat is a retired compatibility key: it always reads true and written values are ignored. If someone asks to enable Agent Chat, tell them it is already on, since they are using it now; do not send them to a settings toggle.`,
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
});
|
||||
|
||||
@@ -7309,7 +7314,7 @@ registry.registerPath({
|
||||
path: "/api/instance/settings/experimental",
|
||||
tags: ["instance"],
|
||||
summary: "Update experimental instance settings",
|
||||
description: "Requires instance-admin access or a trusted Cloud stack admin. Operator-hidden settings and Cloud-managed feature restrictions still apply. This does not grant access to other instance administration operations. Agent Chat is always on for every person on every instance and needs no setup. enableAgentChat is a retired compatibility key: it always reads true and written values are ignored. If someone asks to enable Agent Chat, tell them it is already on, since they are using it now; do not send them to a settings toggle.",
|
||||
description: `${EXPERIMENTAL_SETTINGS_PAGE} Requires instance-admin access or a trusted Cloud stack admin. Operator-hidden settings and Cloud-managed feature restrictions still apply. This does not grant access to other instance administration operations. Agent Chat is always on for every person on every instance and needs no setup. enableAgentChat is a retired compatibility key: it always reads true and written values are ignored. If someone asks to enable Agent Chat, tell them it is already on, since they are using it now; do not send them to a settings toggle.`,
|
||||
request: { body: jsonBody(patchInstanceExperimentalSettingsSchema) },
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user