mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
fix(authz): name the environment settings page when an agent is denied an environment route (#20880)
## 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 `<prefix>` 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/<companyId>/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](/<prefix>/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 <noreply@anthropic.com>
This commit is contained in:
1 parent
854b4bcac4
commit
b96967a97b
4 files changed
+61
-4
No files matched your search
@@ -48,6 +48,11 @@ 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/companies/${COMPANY_ID}/environments`, "[Open environment settings](/<prefix>/company/settings/instance/environments)"],
|
||||
["get", `/api/companies/${COMPANY_ID}/environments/capabilities`, "[Open environment settings](/<prefix>/company/settings/instance/environments)"],
|
||||
["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)"],
|
||||
] 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);
|
||||
|
||||
@@ -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](/<prefix>/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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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", "/<prefix>/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 <prefix>.`
|
||||
: "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.";
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user