From e5d7678e4d85746c7a47ec419eb42e7776d6a8b3 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 11 Nov 2026 11:51:54 -0600 Subject: [PATCH] fix(authz): name the environment settings page when an agent is denied an environment route Agents asked which computer can run a coding harness hit the board-only environment routes and got a denial that named no page, so they could not link the person to the control. Environment denials now link environment settings (unless an operator hides them) and say testing and changing computers are board-only too. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/board-access-denial.test.ts | 29 +++++++++++++++++++ server/src/routes/authz.ts | 17 ++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/board-access-denial.test.ts b/server/src/__tests__/board-access-denial.test.ts index 04da1f29281..cb89c8b65f6 100644 --- a/server/src/__tests__/board-access-denial.test.ts +++ b/server/src/__tests__/board-access-denial.test.ts @@ -48,6 +48,10 @@ describe("board-only denial", () => { ["get", "/api/plugins/ui-contributions", "[Open plugin settings](//company/settings/instance/plugins)"], ["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/companies/${COMPANY_ID}/environments`, "[Open environment settings](//company/settings/instance/environments)"], + ["get", `/api/companies/${COMPANY_ID}/environments/capabilities`, "[Open environment settings](//company/settings/instance/environments)"], + ["post", `/api/environments/${AGENT_ID}/probe`, "[Open environment settings](//company/settings/instance/environments)"], + ["get", `/api/environment-leases/${AGENT_ID}`, "[Open environment settings](//company/settings/instance/environments)"], ] 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 +126,31 @@ describe("board-only denial", () => { } }); + it("tells an agent denied an environment route that testing and changing computers are board-only too", async () => { + // An agent asked which computer can run a harness reads the capabilities first. That + // denial must also settle probing, or the agent keeps searching for a route to test one. + const res = await request(createApp(agentActor, assertBoardOrgAccess)).get(`/api/companies/${COMPANY_ID}/environments/capabilities`).expect(403); + expect(res.body.remediation).toContain("Execution environments are board-only throughout"); + expect(res.body.remediation).toContain("testing (probing) them"); + // The agent-readable project environment options are a different route. + const other = await request(createApp(agentActor)).get(`/api/companies/${COMPANY_ID}/project-environments`).expect(403); + expect(other.body.remediation).not.toContain("Execution environments"); + expect(other.body.remediation).not.toContain("/instance/environments"); + }); + + it("does not link environment settings while the operator hides them", async () => { + const previous = process.env.PAPERCLIP_HIDDEN_SETTINGS; + process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.environments"; + try { + const res = await request(createApp(agentActor)).get(`/api/companies/${COMPANY_ID}/environments`).expect(403); + expect(res.body.remediation).toContain("this denial is final"); + expect(res.body.remediation).not.toContain("/instance/environments"); + } 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 4fab0967fa7..827fd0b98da 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -95,6 +95,17 @@ function pluginControlPage(path: string): [string, string] | null { const PLUGIN_ROUTE = /^\/plugins(?:\/|$)/; const PLUGIN_ADMINISTRATION_SCOPE = "Plugin management is board-only throughout: listing, installing, configuring (including choosing a secret for a plugin setting), enabling, disabling, upgrading and removing plugins have no agent route either."; +// Execution environments (the computers agents run on) are managed on one settings +// page. Every environment route is board-only, so a denial on the first one an agent +// reads (usually the list or the capabilities) also answers testing and changing them. +// An operator can hide the page, so link it only while it is visible. +const ENVIRONMENT_ROUTE = /^\/(?:companies\/[^/]+\/)?environments(?:\/|$)|^\/environment-leases(?:\/|$)/; +const ENVIRONMENT_ADMINISTRATION_SCOPE = "Execution environments are board-only throughout: listing computers, reading which coding harnesses they support, testing (probing) them and changing them have no agent route either."; +function environmentControlPage(path: string): [string, string] | null { + if (!ENVIRONMENT_ROUTE.test(path) || getHiddenSettings().has("instance.environments")) return null; + return ["environment settings", "//company/settings/instance/environments"]; +} + function deniedApiPath(req: Request) { return (req.originalUrl ?? "").split("?")[0]!.replace(/^\/api(?=\/)/, ""); } @@ -103,6 +114,8 @@ function boardControlPage(req: Request) { const path = deniedApiPath(req); const pluginPage = pluginControlPage(path); if (pluginPage) return pluginPage; + const environmentPage = environmentControlPage(path); + if (environmentPage) return environmentPage; for (const [pattern, page] of BOARD_CONTROL_PAGES) { const match = pattern.exec(path); if (match) return page(match, req); @@ -117,7 +130,9 @@ function boardControlPage(req: Request) { export function boardAccessRequired(req: Request, controlPage?: BoardControlPage) { if (req.actor.type !== "agent") return forbidden("Board access required"); const page = controlPage ?? boardControlPage(req); - const scope = PLUGIN_ROUTE.test(deniedApiPath(req)) ? `${PLUGIN_ADMINISTRATION_SCOPE} ` : ""; + const deniedPath = deniedApiPath(req); + const scope = PLUGIN_ROUTE.test(deniedPath) ? `${PLUGIN_ADMINISTRATION_SCOPE} ` + : ENVIRONMENT_ROUTE.test(deniedPath) ? `${ENVIRONMENT_ADMINISTRATION_SCOPE} ` : ""; const where = page ? `They can do it in the Paperclip web app: link [Open ${page[0]}](${page[1]}), with this conversation's company prefix as .` : "They can do it in the Paperclip web app or with their own board credentials. Link a page only if you already know its route; do not search for one.";