From 43fe87795af83a49d86453fcd4adbb7e34c5167d Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:28:52 -0600 Subject: [PATCH] fix(authz): make board-only denials final and actionable for agents (#20151) 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 > - People talk to their agents in Agent Chat, and the agents act through the Paperclip API > - Many API routes are board-only. The shared `assertBoard` check returns 403 `"Board access required"` to an agent, with no other detail > - The chat guidance also told the agent to "use permitted discovery to identify the supported operator path" after such a denial > - So the agent keeps searching the API, `call_api` and the shell for a second route. On a slow model, this search uses the whole reply window and the person gets no answer > - Earlier single-route fixes (#19990, #20041, #20008) showed the remedy: a denial that says it is final and names where the person acts. Reply time fell from 10-24 minutes to about 2 minutes > - This pull request applies that remedy once, in the shared board-only check and in the chat guidance > - The benefit is a prompt, correct handoff for every board-only action that an agent cannot do ## Linked Issues or Issue Description Refs #19990, #20008, #20041 (same remedy for single routes). **What happened** In about 30 Agent Chat evaluation cases on a slower model, the agent got 403 "Board access required" (or read `authorization.actor: "board"` in the API catalog). Then it searched for another route until the attempt deadline. Examples: archive a company, set up a managed agent profile, add a company secret. In one recorded run, the agent got the 403 at minute 1, then tried PATCH, environment variables, the CLI and UI paths for more than 3 more minutes, and never replied. **Expected behavior** A board-only denial is final. The agent stops, tells the person that a board user must do the action, links the page in the Paperclip web app where they can do it, and ends its reply. **Steps to reproduce** 1. Open Agent Chat with a CEO agent. 2. Ask it to archive the company (or to add a company secret). 3. The agent calls `POST /api/companies/{id}/archive` and gets `{ "error": "Board access required" }`. 4. The body says nothing about finality or where the person acts. The agent searches for other routes and replies late or not at all. ## What Changed - `server/src/routes/authz.ts`: `assertBoard` now throws `boardAccessRequired(req)`. For an agent actor, the 403 body adds `code: "board_access_required"` and a `remediation`. The remediation says the action is board-only by design, the denial is final, and no other route can do it. When the API path maps to a known board page, it links that page with a `` placeholder: company settings, company secrets, budgets, members, tool gateways, connection settings (decision model, prompt injection prevention), an approval, or the agent profile tab that holds the control (API keys, permissions, budget, identity, harness settings). Otherwise it says to link a page only when the route is already known, and not to search for one. Board and anonymous actors get the same body as before. - `environments.ts`, `instance-settings.ts` and the issue force-release route used their own copy of the check. They now call `assertBoard`, so they return the same denial. Status and error text do not change. - `server/src/middleware/error-handler.ts`: for this code, the handler does not repeat `code` and `remediation` inside `details`. They are already top-level, and the copy only made the agent's context longer. - `AGENT_CHAT_DIRECTIVE`: the board-identity paragraph now says that a board-only operation (catalog actor `board`, or a 403 "Board access required") is final. The agent must stop searching, tell the person which control in the web app to use, and link it when it knows the route. The old instruction to "discover the operator path" is removed. - Claude Managed Agent setup: the Paperclip Runner configuration doc and the `POST /api/companies/{companyId}/managed-agent-profiles` catalog entry now say that a board operator creates and qualifies the profile (limited environment, retention acknowledgement) and that agents cannot. Before, an agent asked for an "Anthropic managed agent" explored hiring and adapter docs for its whole reply window. - Tests: new `board-access-denial.test.ts` covers the agent body for each mapped page, the agent profile tabs, the general fallback, the unmapped fast-response route, the org-access guard, the unchanged non-agent body, the guidance and the managed profile catalog entry. Two exact-body tests now also expect the new code. ## Verification - `cd server && npx vitest run src/__tests__/board-access-denial.test.ts src/__tests__/costs-service.test.ts src/__tests__/browser-use-route-scope.test.ts src/__tests__/primary-agent-guidance.test.ts src/__tests__/agent-cross-tenant-authz-routes.test.ts src/__tests__/company-portability-routes.test.ts src/__tests__/issue-comment-cancel-routes.test.ts src/__tests__/error-handler.test.ts src/__tests__/managed-agent-profile-routes-authz.test.ts src/__tests__/companies-route-cross-company-authz.test.ts src/__tests__/environment-routes.test.ts src/__tests__/environment-instance-routes.test.ts`: all pass. - Real chat journeys on the chat evaluation branch (runner-opencode, `deepseek-v4.1-flash`). Before this change, each case below got no reply before its deadline: - `companies.archive-company`: passed. Reply 47 s after the request (before: none in 270 s). The agent read the remediation and linked `/COM/company/settings`. - `companies.configure-decision-model`: passed. Reply 68 s after the request (before: none in 389 s). It linked `/COM/company/settings/connections`. - `companies.prompt-injection-guard`: reply 85 s after the request (before: none in 390 s), with the correct handoff and link. The case still fails later, in the scripted settings step: the saved model is the default, not the model the script typed. That is a separate defect. - `navigation-preferences.sidebar-order`: reply 99 s after the request (before: none in 150 s). The case still fails in its scripted sidebar step (a locator was not found). - `secrets.configure-company-secret`: reply 6.5 min after the request (before: none in 7.5 min). The reply is correct, but a grader keyword check reads "chat is not a safe place to send it" as a request for the secret. - `agents.managed-profile`: still no reply in its 150 s window. After the catalog change, the agent found the board-only profile setup within 30 s, but then spent about 90 s deciding what "Anthropic managed agent" means. ## Risks - Low risk. The HTTP status and the `error` text stay the same for every actor. Board users and anonymous callers get the same body as before. - Agent actors now get two more top-level fields (`code`, `remediation`). A client that compares the whole 403 body of an agent call exactly will see a change. Two server tests did this and are updated. - The page map uses the API path. A wrong map entry gives a less useful link but does not change authorization. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with 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 | 91 +++++++++++++++++++ .../__tests__/browser-use-route-scope.test.ts | 3 +- server/src/__tests__/costs-service.test.ts | 4 +- server/src/adapters/registry.ts | 2 +- server/src/middleware/error-handler.ts | 3 + server/src/routes/authz.ts | 57 +++++++++++- server/src/routes/environments.ts | 6 +- server/src/routes/instance-settings.ts | 6 +- server/src/routes/issues.ts | 5 +- server/src/routes/openapi.ts | 1 + server/src/services/agent-conversations.ts | 2 +- 11 files changed, 162 insertions(+), 18 deletions(-) create mode 100644 server/src/__tests__/board-access-denial.test.ts diff --git a/server/src/__tests__/board-access-denial.test.ts b/server/src/__tests__/board-access-denial.test.ts new file mode 100644 index 00000000000..582cc11e6c4 --- /dev/null +++ b/server/src/__tests__/board-access-denial.test.ts @@ -0,0 +1,91 @@ +import express from "express"; +import request from "supertest"; +import { describe, expect, it } from "vitest"; + +import { errorHandler } from "../middleware/index.js"; +import { assertBoard, assertBoardOrgAccess, BOARD_ACCESS_REQUIRED_CODE } from "../routes/authz.js"; +import { AGENT_CHAT_DIRECTIVE } from "../services/agent-conversations.js"; +import { buildOpenApiDocument } from "../routes/openapi.js"; + +const COMPANY_ID = "10000000-0000-4000-8000-000000000001"; +const AGENT_ID = "20000000-0000-4000-8000-000000000002"; + +const agentActor: Express.Request["actor"] = { type: "agent", source: "agent_key", agentId: "agent-1", companyId: COMPANY_ID }; +const boardActor: Express.Request["actor"] = { type: "board", source: "local_implicit", userId: "local-board" }; + +function createApp(actor: Express.Request["actor"], guard = assertBoard) { + const app = express(); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + const router = express.Router(); + router.all(/.*/, (req, res) => { + guard(req); + res.json({ ok: true }); + }); + app.use("/api", router); + app.use(errorHandler); + return app; +} + +describe("board-only denial", () => { + it.each([ + ["post", `/api/companies/${COMPANY_ID}/archive`, "[Open company settings](//company/settings)"], + ["post", `/api/companies/${COMPANY_ID}/secrets`, "[Open company secrets](//company/settings/secrets)"], + ["post", `/api/secrets/${AGENT_ID}/rotate`, "[Open company secrets](//company/settings/secrets)"], + ["post", `/api/companies/${COMPANY_ID}/budget-incidents/i-1/resolve?x=1`, "[Open budgets](//activity/budgets)"], + ["put", `/api/companies/${COMPANY_ID}/prompt-injection-prevention`, "[Open connection settings](//company/settings/connections)"], + ["patch", `/api/agents/${AGENT_ID}/permissions`, `[Open the agent's permissions](//agents/${AGENT_ID}/permissions)`], + ["post", `/api/agents/${AGENT_ID}/keys`, `[Open the agent's API keys](//agents/${AGENT_ID}/api-keys)`], + ["post", `/api/agents/${AGENT_ID}/pause`, `[Open the agent's profile](//agents/${AGENT_ID})`], + ] 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"); + expect(res.body.code).toBe(BOARD_ACCESS_REQUIRED_CODE); + expect(res.body.remediation).toContain("this denial is final"); + expect(res.body.remediation).toContain("do not search for one"); + expect(res.body.remediation).toContain(link); + }); + + it("falls back to a general board instruction when no page is known", async () => { + const res = await request(createApp(agentActor)).get(`/api/companies/${COMPANY_ID}/managed-agent-profiles`).expect(403); + expect(res.body.code).toBe(BOARD_ACCESS_REQUIRED_CODE); + expect(res.body.remediation).toContain("this denial is final"); + expect(res.body.remediation).toContain("Paperclip web app or with their own board credentials"); + expect(res.body.remediation).toContain("Link a page only if you already know its route; do not search for one"); + expect(res.body.remediation).not.toContain("](/"); + }); + + it("does not link connection settings for fast responses, whose controls an experiment can hide", async () => { + const res = await request(createApp(agentActor)).put(`/api/companies/${COMPANY_ID}/fast-response`).expect(403); + expect(res.body.remediation).not.toContain("/company/settings/connections"); + }); + + 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); + expect(res.body.remediation).toContain("[Open company settings](//company/settings)"); + }); + + it("keeps the plain denial for actors that are not agents", async () => { + const res = await request(createApp({ type: "none", source: "none" })).post(`/api/companies/${COMPANY_ID}/archive`).expect(403); + expect(res.body).toEqual({ error: "Board access required" }); + await request(createApp(boardActor)).post(`/api/companies/${COMPANY_ID}/archive`).expect(200); + }); + + it("chat guidance treats a board-only operation as final", () => { + expect(AGENT_CHAT_DIRECTIVE).toContain("its API catalog authorization actor is board, or a call returns 403 \"Board access required\""); + expect(AGENT_CHAT_DIRECTIVE).toContain("that boundary is final"); + expect(AGENT_CHAT_DIRECTIVE).toContain("so stop searching for one"); + expect(AGENT_CHAT_DIRECTIVE).toContain("tell the person which control in the Paperclip web app to use, link its page when you know the route"); + expect(AGENT_CHAT_DIRECTIVE).not.toContain("use permitted discovery to identify the supported operator path"); + }); + + it("says in the API catalog who sets up a Claude Managed Agent profile", () => { + const post = buildOpenApiDocument().paths["/api/companies/{companyId}/managed-agent-profiles"].post; + expect(post["x-paperclip-authorization"]).toEqual({ actor: "board" }); + expect(post.description).toContain("Board only: agent credentials get 403"); + expect(post.description).toContain("explicitly acknowledges stateful retention"); + }); +}); diff --git a/server/src/__tests__/browser-use-route-scope.test.ts b/server/src/__tests__/browser-use-route-scope.test.ts index 113b4081849..df017d48fe7 100644 --- a/server/src/__tests__/browser-use-route-scope.test.ts +++ b/server/src/__tests__/browser-use-route-scope.test.ts @@ -64,7 +64,8 @@ describe("browser task route scope", () => { actorType = type; const response = await http(app)[method](path); expect(response.status).toBe(type === "board" ? 404 : 403); - expect(response.body).toEqual({ error: type === "board" ? "Task not found" : "Board access required" }); + expect(response.body).toMatchObject({ error: type === "board" ? "Task not found" : "Board access required" }); + expect(response.body.code).toBe(type === "agent" ? "board_access_required" : undefined); } } expect(select).not.toHaveBeenCalled(); diff --git a/server/src/__tests__/costs-service.test.ts b/server/src/__tests__/costs-service.test.ts index 62380d7f490..06a393eadf1 100644 --- a/server/src/__tests__/costs-service.test.ts +++ b/server/src/__tests__/costs-service.test.ts @@ -341,7 +341,7 @@ describe("cost routes", () => { .send({ budgetMonthlyCents: 2500 }); expect(res.status).toBe(403); - expect(res.body).toEqual({ error: "Board access required" }); + expect(res.body).toMatchObject({ error: "Board access required", code: "board_access_required" }); expect(mockAgentService.update).not.toHaveBeenCalled(); expect(mockBudgetService.upsertPolicy).not.toHaveBeenCalled(); expect(mockLogActivity).not.toHaveBeenCalled(); @@ -360,7 +360,7 @@ describe("cost routes", () => { .send({ budgetMonthlyCents: 2500 }); expect(res.status).toBe(403); - expect(res.body).toEqual({ error: "Board access required" }); + expect(res.body).toMatchObject({ error: "Board access required", code: "board_access_required" }); expect(mockAgentService.update).not.toHaveBeenCalled(); expect(mockBudgetService.upsertPolicy).not.toHaveBeenCalled(); expect(mockLogActivity).not.toHaveBeenCalled(); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index d5b38bea0b7..a6f822946fc 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -644,7 +644,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = { ) : buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex@0.160.0"), agentConfigurationDoc: - "# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or ACPX Claude/Grok Build/Cursor/Pi through the Rust Paperclip runner and authenticated PRP transport. Pi accepts an explicit provider/model ID and uses the company credentials bound to the agent environment. GitHub Copilot requires a saved GitHub token and an explicitly selected available model. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n", + "# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or ACPX Claude/Grok Build/Cursor/Pi through the Rust Paperclip runner and authenticated PRP transport. Pi accepts an explicit provider/model ID and uses the company credentials bound to the agent environment. GitHub Copilot requires a saved GitHub token and an explicitly selected available model. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits. A board operator creates each Claude Managed (Anthropic managed agent) profile through POST /api/companies/{companyId}/managed-agent-profiles; agent credentials cannot create, qualify or list one.\n", getConfigSchema: () => ({ fields: [ { diff --git a/server/src/middleware/error-handler.ts b/server/src/middleware/error-handler.ts index 438a27acda6..d374e5f37d1 100644 --- a/server/src/middleware/error-handler.ts +++ b/server/src/middleware/error-handler.ts @@ -174,6 +174,8 @@ export function errorHandler( const redactedSkillPolicyDenial = isRedactedSkillPolicyDenial(details); const workspaceRepairPreconditionFailure = details?.code === "workspace_repair_precondition_failed"; + // Its code and remediation are already top-level; repeating them only lengthens agent context. + const boardAccessDenial = details?.code === "board_access_required"; const structuredConnectionError = new Set([ "user_authorization_required", "organization_authorization_required", @@ -255,6 +257,7 @@ export function errorHandler( : {}), ...(!redactedSkillPolicyDenial && !workspaceRepairPreconditionFailure && + !boardAccessDenial && responseDetailsValue ? { details: responseDetailsValue } : {}), diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index 2c3f6359cbc..10d2400fd55 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -29,9 +29,64 @@ export function assertAuthenticated(req: Request) { } } +export const BOARD_ACCESS_REQUIRED_CODE = "board_access_required"; + +// Agent API subresources whose controls live on their own profile tab. +const AGENT_PROFILE_TABS: Record = { + keys: ["API keys", "api-keys"], + permissions: ["permissions", "permissions"], + budgets: ["budget", "budgets"], + identity: ["identity and instructions", "identity"], + "instructions-bundle": ["identity and instructions", "identity"], + "instructions-path": ["identity and instructions", "identity"], + configuration: ["harness settings", "runtime"], + "runtime-state": ["harness settings", "runtime"], +}; + +// The board page that holds the control behind a board-only API route, when one +// is known. Paths keep for the agent to fill from its own task identifier. +const BOARD_CONTROL_PAGES: Array<[RegExp, (match: RegExpExecArray) => [string, string]]> = [ + [/^\/(?:companies\/[^/]+\/)?secrets?(?:[-/]|$)/, () => ["company secrets", "//company/settings/secrets"]], + [/^\/companies\/[^/]+\/(?:budgets|budget-incidents|costs)(?:\/|$)/, () => ["budgets", "//activity/budgets"]], + [/^\/companies\/[^/]+\/(?:prompt-injection-prevention|decision-model)(?:\/|$)/, () => ["connection settings", "//company/settings/connections"]], + [/^\/companies\/[^/]+\/(?:members|invites|join-requests)(?:\/|$)/, () => ["company members", "//company/settings/members"]], + [/^\/companies\/[^/]+\/(?:tools\/)?gateways(?:\/|$)|^\/tool-gateway\//, () => ["tool gateways", "//apps/gateways"]], + [/^\/approvals\/([^/]+)/, match => ["the approval", `//approvals/${match[1]}`]], + [/^\/agents\/([0-9a-f-]{36})(?:\/([^/]+))?(?:\/|$)/i, match => { + const tab = match[2] && Object.hasOwn(AGENT_PROFILE_TABS, match[2]) ? AGENT_PROFILE_TABS[match[2]] : undefined; + return tab ? [`the agent's ${tab[0]}`, `//agents/${match[1]}/${tab[1]}`] : ["the agent's profile", `//agents/${match[1]}`]; + }], + [/^\/companies\/[^/]+(?:\/(?:archive|branding|settings))?\/?$/, () => ["company settings", "//company/settings"]], +]; + +function boardControlPage(req: Request) { + const path = (req.originalUrl ?? "").split("?")[0]!.replace(/^\/api(?=\/)/, ""); + for (const [pattern, page] of BOARD_CONTROL_PAGES) { + const match = pattern.exec(path); + if (match) return page(match); + } + return null; +} + +/** + * A board-only denial. Agents get a final, actionable remediation so they hand the + * action to the person instead of searching other routes for one that does not exist. + */ +export function boardAccessRequired(req: Request) { + if (req.actor.type !== "agent") return forbidden("Board access required"); + const page = boardControlPage(req); + 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."; + return forbidden("Board access required", { + code: BOARD_ACCESS_REQUIRED_CODE, + remediation: `This action is board-only by design and this denial is final: no agent credential, tool or other API route can do it, so do not search for one. Tell the person that a board user must do it. ${where}`, + }); +} + export function assertBoard(req: Request) { if (req.actor.type !== "board") { - throw forbidden("Board access required"); + throw boardAccessRequired(req); } } diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 7acd2f1fddb..3260ae6c989 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 { assertBoardOrgAccess, getActorInfo } from "./authz.js"; +import { assertBoard, assertBoardOrgAccess, 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"; @@ -400,9 +400,7 @@ export function environmentRoutes( } function assertCustomImageCompanyAccess(req: Request, companyId: string) { - if (req.actor.type !== "board") { - throw forbidden("Board access required"); - } + assertBoard(req); if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; const allowedCompanies = req.actor.companyIds ?? []; if (!allowedCompanies.includes(companyId)) { diff --git a/server/src/routes/instance-settings.ts b/server/src/routes/instance-settings.ts index 0d7d28c588f..3372b295792 100644 --- a/server/src/routes/instance-settings.ts +++ b/server/src/routes/instance-settings.ts @@ -32,7 +32,7 @@ import { } from "../services/index.js"; import { environmentService } from "../services/environments.js"; import { assertEnvironmentSelectionForCompany } from "./environment-selection.js"; -import { assertBoardOrgAccess, getActorInfo } from "./authz.js"; +import { assertBoard, assertBoardOrgAccess, getActorInfo } from "./authz.js"; function sameJsonValue(a: unknown, b: unknown): boolean { if (a === b) return true; @@ -90,9 +90,7 @@ function publishActivitiesBestEffort(publications: ActivityPublication[], action } function assertCanManageInstanceSettings(req: Request) { - if (req.actor.type !== "board") { - throw forbidden("Board access required"); - } + assertBoard(req); if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) { return; } diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index e3931c8e5bd..d87ca4fb3a7 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -17017,10 +17017,7 @@ export function issueRoutes( }); router.post("/issues/:id/admin/force-release", async (req, res) => { - if (req.actor.type !== "board") { - res.status(403).json({ error: "Board access required" }); - return; - } + assertBoard(req); if (!req.actor.userId) { throw forbidden("Board user context required"); } diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index b2bc94cb5f3..848d0ee3899 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -7560,6 +7560,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/managed-agent-profiles", tags: ["agents"], summary: "Create or operator-attest a Claude Managed Agent profile", + description: "Board only: agent credentials get 403. A Claude Managed Agent runs on Anthropic's managed-agents beta. A board operator creates the company profile with an Anthropic API-key secret and a limited environment (no hosts, no packages), attests its qualification and explicitly acknowledges stateful retention before it can be enabled.", request: { params: z.object({ companyId: z.string() }), body: jsonBody( diff --git a/server/src/services/agent-conversations.ts b/server/src/services/agent-conversations.ts index 0f6cbf19979..2f81391892c 100644 --- a/server/src/services/agent-conversations.ts +++ b/server/src/services/agent-conversations.ts @@ -93,7 +93,7 @@ export const AGENT_CHAT_DIRECTIVE = `You are in an ongoing conversation with the When changing defaults or policy for new or future work, update the resource that owns those defaults. Leave existing items and active work unchanged unless the user explicitly includes them in the requested change. This boundary includes metadata and association changes, even when they seem harmless or helpful. Do not broaden the request to align related existing items. Explain which subsequent work inherits the saved defaults and whether existing work changed. -If the current execution lacks the board or instance-operator identity required by an operation, use permitted discovery to identify the supported operator path, complete other permitted work, then explain the required handoff and end your response. A user's request or confirmation does not expand your credential's scope. Treat permission denials as boundaries: do not probe host processes, private control files, or unrelated endpoints for alternate access. Distinguish a requested action from a verified action. +If an operation needs board or instance-operator identity that this execution lacks (its API catalog authorization actor is board, or a call returns 403 "Board access required"), that boundary is final: no agent credential, tool or other route can do it, so stop searching for one. Complete other permitted work, then tell the person which control in the Paperclip web app to use, link its page when you know the route (a denial's remediation may name it), and end your response. A user's request or confirmation does not expand your credential's scope. Treat permission denials as boundaries: do not probe host processes, private control files, or unrelated endpoints for alternate access. Distinguish a requested action from a verified action. Research, clarify, and develop full plans here using the conversation's plan document. Revise the draft as the discussion develops. Planning alone does not create execution tasks. Put implementation and substantial execution into separate tasks.