fix(authz): name the Experimental settings page when an agent is denied an instance experiment (#20894)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - People ask chat agents to turn on instance experiments, such as
Cases
> - Agents cannot read or change experimental instance settings, which
is correct
> - The denial they get names no page, so the agent cannot tell the
person where to act
> - The case API gate ("Cases are disabled") also names no page
> - This pull request makes both denials, and the API catalog entry,
name the Experimental settings page
> - The benefit is that the agent replies with a usable link instead of
a vague "ask an admin"

## Linked Issues or Issue Description

**What happened**
An agent that asks for `GET` or `PATCH
/api/instance/settings/experimental` gets the generic board-only denial.
That denial says "Link a page only if you already know its route; do not
search for one." The case API, while Cases is off, returns only "Cases
are disabled". The agent has no route to give the person.

**Expected behavior**
The denial is final and names the page where an instance admin turns the
feature on: `[Open Experimental
settings](/<prefix>/company/settings/instance/experimental)`. If the
operator hides that page, the denial does not link it.

**Steps to reproduce**
1. Use an agent API key.
2. Send `PATCH /api/instance/settings/experimental` with
`{"enableCases": true}`.
3. See a 403 with a remediation that names no page.
4. With Cases off, send `GET /api/companies/<id>/cases` with the agent
key.
5. See a 403 "Cases are disabled" with no remediation.

Refs the chat eval `instance-operations.instance-settings` (retargeted
from the removed Agent Chat toggle to Cases).

## What Changed

- `server/src/routes/authz.ts`: the board-only denial map links
Experimental settings for `/instance/settings/experimental`. It does not
link it when `instance.experimental` is hidden.
- `server/src/routes/cases.ts`: the Cases gate keeps the "Cases are
disabled" message. It adds `code: cases_disabled` and a final
remediation that names the same page (same hidden-page guard).
- `server/src/routes/openapi.ts`: the GET and PATCH catalog entries for
experimental settings name the page. They say that agent credentials
cannot read or change these settings. They give Cases (`enableCases`) as
an example.
- Regression tests in `board-access-denial.test.ts`,
`cases-routes.test.ts` and `openapi-routes.test.ts`.

## Verification

- `cd server && pnpm exec vitest run
src/__tests__/board-access-denial.test.ts
src/__tests__/openapi-routes.test.ts
src/__tests__/cases-routes.test.ts`: 107 passed.
- Red check: with the three source files reverted, 4 new assertions fail
(GET and PATCH denial links, the catalog entry, the Cases gate).
- `src/services/native-runtime/runner-api.test.ts` and
`src/__tests__/instance-settings-routes.test.ts`: pass.

## Risks

- Low risk. Only error bodies and catalog text change. Status codes and
the "Cases are disabled" message stay the same.
- The catalog text is static, so it names the page even when an operator
hides it. The denials check the hidden setting.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code
execution in Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-11 13:14:42 -06:00
1 parent a589b74fbf
commit 6154b9485e
6 files changed
+92 -10

No files matched your search

@@ -53,6 +53,8 @@ describe("board-only denial", () => {
["post", `/api/environments/${AGENT_ID}/probe`, "[Open environment settings](/<prefix>/company/settings/instance/environments)"],
["get", `/api/environment-leases/${AGENT_ID}`, "[Open environment settings](/<prefix>/company/settings/instance/environments)"],
["get", `/api/environment-custom-image-setup-sessions/${AGENT_ID}`, "[Open environment settings](/<prefix>/company/settings/instance/environments)"],
["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");
@@ -149,6 +151,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);
+16 -1
View File
@@ -202,7 +202,22 @@ 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");
// Hiding only the Cases switch keeps the page, so the denial must not send the person there.
const previousHidden = process.env.PAPERCLIP_HIDDEN_SETTINGS;
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.experimental.enableCases";
try {
const hiddenDenial = await http.get(`/api/companies/${company.id}/cases`).expect(403);
expect(hiddenDenial.body.remediation).toContain("An instance operator controls it.");
expect(hiddenDenial.body.remediation).not.toContain("/instance/experimental");
} finally {
if (previousHidden === undefined) delete process.env.PAPERCLIP_HIDDEN_SETTINGS;
else process.env.PAPERCLIP_HIDDEN_SETTINGS = previousHidden;
}
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);
+20 -1
View File
@@ -7,7 +7,7 @@ import { describe, expect, it } from "vitest";
import { MUSE_ASSET_VERSION, MUSE_PUBLIC_ROUTES } from "@paperclipai/shared";
import { COMPANY_IMPORT_TRANSFERS_ROUTE_PATH } from "@paperclipai/shared/company-import-transfer";
import { errorHandler } from "../middleware/index.js";
import { buildOpenApiSpec, openApiRoutes } from "../routes/openapi.js";
import { buildOpenApiSpec, openApiRoutes, applyPluginSettingsVisibility, buildOpenApiDocument } from "../routes/openapi.js";
import { mcpDiscoveryDocuments } from "../services/public-mcp/discovery.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -653,6 +653,25 @@ 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("names the hosting operator instead of a hidden Experimental settings page", () => {
const hidden = applyPluginSettingsVisibility(buildOpenApiDocument(), new Set(["instance.experimental"]));
const experimental = hidden.paths["/api/instance/settings/experimental"];
for (const operation of [experimental.get, experimental.patch]) {
expect(operation.description).toContain("controlled by the hosting operator");
expect(operation.description).not.toContain("/instance/experimental");
}
});
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;
+3
View File
@@ -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
+13 -1
View File
@@ -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,18 @@ 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.
// Hiding only the Cases key keeps the page but drops its switch, so check both.
const hidden = getHiddenSettings();
const where = hidden.has("instance.experimental") || hidden.has("instance.experimental.enableCases")
? "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.`,
});
}
}
+25 -7
View File
@@ -5422,12 +5422,21 @@ const PLUGIN_MANAGED_ROUTINE_OPERATOR_GUIDANCE =
+ "Do not rewrite, detach or duplicate a plugin-managed routine to imitate a change, and do not search for another route: "
+ "reply now that the hosting operator controls this plugin's settings, with the routine's page link.";
/** Picks the plugin-managed routine guidance that matches plugin settings visibility. */
/** Picks the plugin-managed routine and experimental settings guidance that matches settings visibility. */
export function applyPluginSettingsVisibility(document: any, hidden: ReadonlySet<string> = getHiddenSettings()): any {
if (!hidden.has("instance.plugins")) return document;
for (const operation of [document.paths?.["/api/routines/{id}"]?.get, document.paths?.["/api/routines/{id}"]?.patch]) {
if (typeof operation?.description === "string") {
operation.description = operation.description.replace(PLUGIN_MANAGED_ROUTINE_GUIDANCE, PLUGIN_MANAGED_ROUTINE_OPERATOR_GUIDANCE);
if (hidden.has("instance.plugins")) {
for (const operation of [document.paths?.["/api/routines/{id}"]?.get, document.paths?.["/api/routines/{id}"]?.patch]) {
if (typeof operation?.description === "string") {
operation.description = operation.description.replace(PLUGIN_MANAGED_ROUTINE_GUIDANCE, PLUGIN_MANAGED_ROUTINE_OPERATOR_GUIDANCE);
}
}
}
if (hidden.has("instance.experimental")) {
const experimental = document.paths?.["/api/instance/settings/experimental"];
for (const operation of [experimental?.get, experimental?.patch]) {
if (typeof operation?.description === "string") {
operation.description = operation.description.replace(EXPERIMENTAL_SETTINGS_PAGE, EXPERIMENTAL_SETTINGS_OPERATOR_GUIDANCE);
}
}
}
return document;
@@ -7261,6 +7270,15 @@ 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.";
// When the hosting operator hides the Experimental page, it redirects away, so name the operator instead.
const EXPERIMENTAL_SETTINGS_OPERATOR_GUIDANCE = "Instance experimental features (for example Cases via enableCases) are controlled by the hosting operator on this instance, "
+ "so no Paperclip page offers that change. Agent credentials cannot read or change these settings, "
+ "so do not claim a change or search for another route: reply that the hosting operator controls it.";
registry.registerPath({
method: "get",
path: "/api/instance/settings",
@@ -7300,7 +7318,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 +7327,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 },
});