From b96967a97bb66860b528a7962ff71dfc56457761 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:49:19 -0600 Subject: [PATCH] fix(authz): name the environment settings page when an agent is denied an environment route (#20880) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Execution environments are the computers that agents run on. Only a board user can list, inspect, test or change them. > - An agent in chat can be asked "Which computer can run Morgan with Claude Code?". Its first environment read returns 403. > - The 403 says the denial is final, but it names no page. The agent cannot link the person to the control that answers the question. > - Other board-only areas (secrets, plugins, budgets) already name their page in the denial. > - This pull request adds the environment settings page to the agent denial for every environment route, and says that testing and changing computers are board-only too. > - The benefit is that the agent can send the person straight to the correct control, and it does not search for a probe route that does not exist. ## Linked Issues or Issue Description **What happened** An agent asked which computer can run a coding harness called the environment list or capabilities route. The route returned 403 "Board access required" with a remediation that named no page ("Link a page only if you already know its route; do not search for one"). The agent had no route to give the person. **Expected behavior** The denial names the environment settings page as a Markdown link with the `` placeholder, like the denials for secrets and plugins. It also says that the other environment steps (capabilities, probes, changes) are board-only, so the agent replies instead of trying them. When an operator hides environment settings (`PAPERCLIP_HIDDEN_SETTINGS=instance.environments`), the denial does not link the page. **Steps to reproduce** 1. Use an agent API key. 2. Call `GET /api/companies//environments/capabilities`. 3. Read the 403 body. Before this change, `remediation` contains no link. ## What Changed - `server/src/routes/authz.ts`: board-only denials on `/companies/:id/environments...`, `/environments/...` and `/environment-leases/...` now link `[Open environment settings](//company/settings/instance/environments)` and add one sentence that says execution environments are board-only throughout. The link is left out when the operator hides `instance.environments`. `/companies/:id/project-environments` (agent-readable) is not matched. - `server/src/__tests__/board-access-denial.test.ts`: regression tests for the list, capabilities, probe and lease routes, the scope sentence, the project-environments exclusion, and the hidden-settings case. ## Verification - `cd server && pnpm exec vitest run src/__tests__/board-access-denial.test.ts`: 33 passed. With the old `authz.ts`, 5 of the new tests fail (red), and they pass with the change (green). - `cd server && pnpm exec vitest run src/__tests__/environment-routes.test.ts src/__tests__/agent-permissions-routes.test.ts`: 99 and 126 passed. ## Risks - Low risk. Only the agent-facing 403 remediation text changes. Status codes, the error code and board behavior do not change. - The scope sentence is environment-specific, so other denials are unchanged. ## 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 --- .../src/__tests__/board-access-denial.test.ts | 27 +++++++++++++++++++ .../src/__tests__/environment-routes.test.ts | 22 +++++++++++++-- server/src/routes/authz.ts | 13 ++++++++- server/src/routes/environments.ts | 3 ++- 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/server/src/__tests__/board-access-denial.test.ts b/server/src/__tests__/board-access-denial.test.ts index 04da1f29281..2d9f681769f 100644 --- a/server/src/__tests__/board-access-denial.test.ts +++ b/server/src/__tests__/board-access-denial.test.ts @@ -48,6 +48,11 @@ 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)"], + ["get", `/api/environment-custom-image-setup-sessions/${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 +127,28 @@ describe("board-only denial", () => { } }); + it("tells an agent denied an environment route that testing and changing computers are board-only too", async () => { + 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"); + 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/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index 14842a5661c..235621dc517 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -1526,6 +1526,24 @@ describe("environment routes", () => { expect(mockEnvironmentService.list).not.toHaveBeenCalled(); }); + it.each([ + ["get", "/api/companies/company-1/environments/capabilities"], + ["post", "/api/companies/company-1/environments"], + ["post", "/api/environments/env-1/probe"], + ["patch", "/api/environments/env-1"], + ["delete", "/api/environments/env-1"], + ["get", "/api/environments/env-1/leases"], + ] as const)("gives an agent denied %s %s the final environment settings guidance", async (method, path) => { + const app = createApp({ type: "agent", agentId: "agent-1", companyId: "company-1", source: "agent_key", runId: "run-1" }); + const res = await request(app)[method](path).send(method === "post" && path.endsWith("/environments") ? { name: "Agent computer", driver: "local", config: {} } : {}); + expect(res.status).toBe(403); + expect(res.body.error).toBe("Board access required"); + expect(res.body.remediation).toContain("this denial is final"); + expect(res.body.remediation).toContain("Execution environments are board-only throughout"); + expect(res.body.remediation).toContain("[Open environment settings](//company/settings/instance/environments)"); + expect(mockEnvironmentService.create).not.toHaveBeenCalled(); + }); + it("rejects agent detail reads for instance-scoped environments", async () => { mockEnvironmentService.getById.mockResolvedValue(createEnvironment()); mockAgentService.getById.mockResolvedValue({ @@ -1679,7 +1697,7 @@ describe("environment routes", () => { }); expect(res.status).toBe(403); - expect(res.body.error).toContain("board operators"); + expect(res.body.error).toBe("Board access required"); expect(mockEnvironmentService.create).not.toHaveBeenCalled(); }); @@ -2577,7 +2595,7 @@ describe("environment routes", () => { }); expect(res.status).toBe(403); - expect(res.body.error).toContain("board operators"); + expect(res.body.error).toBe("Board access required"); expect(mockEnvironmentService.create).not.toHaveBeenCalled(); }); diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index 4fab0967fa7..83dce412aa6 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -95,6 +95,13 @@ 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."; +const ENVIRONMENT_ROUTE = /^\/(?:companies\/[^/]+\/)?environments(?:\/|$)|^\/environment-(?:leases|custom-image-setup-sessions)(?:\/|$)/; +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 +110,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 +126,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."; diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index ffdad6429df..506fe19ee8f 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -59,7 +59,7 @@ import { assertAgentsReadyForEnvironmentChange, } from "../services/plugin-environment-driver.js"; import { getConfiguredSecretProvider } from "../secrets/configured-provider.js"; -import { assertBoard, assertBoardOrgAccess, getActorInfo } from "./authz.js"; +import { assertBoard, assertBoardOrgAccess, boardAccessRequired, getActorInfo } from "./authz.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; import { environmentService } from "../services/environments.js"; import { SANDBOX_PLATFORM_PROBE_METADATA_KEY } from "../services/environment-custom-image-runtime.js"; @@ -388,6 +388,7 @@ export function environmentRoutes( } function assertCanAccessInstanceEnvironments(req: Request) { + if (req.actor.type === "agent") throw boardAccessRequired(req); if (req.actor.type !== "board") { throw forbidden("Instance environment management is restricted to board operators"); }