Files
PaperClipAI/server/src/routes/authz.ts
T
DottaandClaude Opus 5.5 267f54ef72 fix(chat): make task-not-found, unreachable-MCP and board denials final for agents (#20208)
## 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
> - A board-only denial now tells the agent that it is final and where
the person acts (#20151, #20182). Other refusals and errors still give
no sign that they are final
> - So the agent keeps trying until the reply deadline, and the person
gets no answer. In chat evaluations this happened on a task lookup that
returned 404, on an MCP server that Paperclip could not reach, on a
model question, and after a final budget denial
> - This pull request makes those answers say that they are final and
what the person must do, and it tightens the chat guidance after a
board-only denial
> - The benefit is a prompt, correct reply instead of a long search or
forbidden workarounds

## Linked Issues or Issue Description

Refs #20151, #20182 (same remedy for board-only denials).

**What happened**
In four Agent Chat evaluation cases on a slower model, the agent did not
reply before its deadline:
- Asked to open a task from an old link, the agent got `404
{"error":"Issue not found"}`. It quoted the private-task guidance, then
searched archived projects, activity and the old host for 3 more
minutes.
- Asked to connect an MCP server at an unreachable URL, the agent got
`502 {"error":"fetch failed","code":"runtime_error"}` from the health
check. It retried, then used `curl` on the host and gateway IP addresses
to look for the server.
- Asked which coding models an agent can use, the agent got the
runtime's model list in its first minute. The catalog did not say how to
list OpenRouter models or that these lists answer the question, so it
enumerated OpenRouter with the shell until the deadline.
- Asked to raise a budget, the agent got the final board-only denial.
The chat guidance said "Complete other permitted work", so it probed
approvals and other routes and replied too late.

**Expected behavior**
The answer the agent receives says whether it is final and what the
person must do, with a link when the page is known. The agent replies at
once. It does not search other routes, retry, or contact other network
addresses.

**Steps to reproduce**
1. Open Agent Chat with a CEO agent.
2. Ask it to open a deleted task by its old link, or to connect an MCP
server at a closed local port.
3. The agent calls `GET /api/issues/{id}` (404) or `POST
/api/tool-connections/{id}/health-check` (502 "fetch failed").
4. The body says nothing about finality. The agent keeps searching or
probing and replies late or not at all.

## What Changed

- `GET /api/issues/:id`: for an agent, the 404 body adds `code:
"issue_not_visible"` and a `remediation`. It says that the lookup is
final, that the agent must not search other routes or open a similarly
named task, and that it should reply now and offer sharing or pasting. A
missing task, a private task and another company's task get the same
body, so the body reveals nothing. People and other routes keep the
plain body.
- MCP health check and catalog refresh: when Paperclip cannot reach the
server (connection refused, DNS, network, reset, timeout or TLS), an
agent's error adds a `remediation`. It says the person must correct the
URL or start the server, that the agent must not contact other hosts,
ports or network addresses, and it links Connectors (`/<prefix>/apps`).
Status, `error` and `code` do not change.
- `AGENT_CHAT_DIRECTIVE`: after a board-only denial, the agent replies
now instead of making more calls. "Complete other permitted work" is
removed.
- API catalog: `POST /api/companies/{companyId}/tools/connections` says
it saves without a check and points MCP-by-URL setup at
`tools/apps/connect`, which checks the server first and saves nothing
for a fresh setup that cannot reach it. `GET
/api/companies/{companyId}/adapters/{type}/models` says which lists
answer a model question (the runtime's list with `provider`, `acpxAgent`
for ACPX agents and `environmentId`, and `opencode_local` with
`provider=openrouter` for OpenRouter keys), that the OpenRouter list is
not a qualification, and that with those inputs these calls answer the
question. `openrouter` is added to the documented `provider` values.
- API catalog authorization: budget policy upsert, budget incident
resolution, company and agent budget updates, and approval
approve/reject are now listed as board-only. Their routes already call
`assertBoard`, but the catalog said `board_or_agent`, so an agent
explored them as its own action before it got the 403.
- Tests: new `agent-refusal-finality.test.ts` (including the catalog
actor for the budget and approval routes); the private-task access test
now checks that a private and a missing task give an agent the same 404
body; the guidance test checks the new wording.

## Verification

- `cd server && npx vitest run
src/__tests__/agent-refusal-finality.test.ts
src/__tests__/board-access-denial.test.ts
src/__tests__/issue-access-grants-routes.test.ts
src/__tests__/authz-existence-oracle-guard.test.ts`: all pass (embedded
Postgres included).
- `cd server && npx vitest run src/__tests__/error-handler.test.ts
src/__tests__/generic-mcp-connection.test.ts
src/__tests__/openapi-routes.test.ts
src/__tests__/remote-http-rebinding.test.ts
src/__tests__/cloud-portfolio-error.test.ts
src/__tests__/database-backup-catalog-guidance.test.ts`: 183 tests pass.
- `npx tsc --noEmit -p server`: passes.
- Real chat journeys on the chat evaluation branch (runner-opencode):
the results will follow in a comment.

## Risks

- Low risk. HTTP status, `error` and `code` stay the same for each
changed error. Agents get extra top-level fields (`code` on the task
404, `remediation`). A client that compares a whole agent 404 body for
`GET /api/issues/:id` exactly will see a change.
- The task 404 body is the same for missing, private and other-company
tasks, so it adds no existence oracle. Other issue routes keep their
plain body.
- The guidance change can make an agent stop after a board-only denial
in a request that had other, permitted parts. The agent still reports
what it finished.

## 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 <noreply@anthropic.com>
2026-11-09 04:30:19 -06:00

411 lines
17 KiB
TypeScript

import type { Request, Response } from "express";
import type { SecretBindingTargetType } from "@paperclipai/shared";
import { forbidden, HttpError, unauthorized } from "../errors.js";
import { logger } from "../middleware/logger.js";
import { responsibleUserAuthzShadowMode } from "../services/authorization.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
function throwOrShadowResponsibleUserCompanyAccessDeny(
req: Request,
companyId: string,
code: "RESPONSIBLE_USER_UNAUTHORIZED" | "RESPONSIBLE_USER_UNAVAILABLE",
message: string,
) {
logger.warn({
authzMode: responsibleUserAuthzShadowMode() ? "shadow" : "enforce",
code,
action: "company_access",
companyId,
actorAgentId: req.actor.agentId ?? null,
responsibleUserId: req.actor.onBehalfOfUserId ?? null,
method: req.method,
}, "responsible-user company access intersection denied");
if (responsibleUserAuthzShadowMode()) return;
throw new HttpError(403, message, { code });
}
export function assertAuthenticated(req: Request) {
if (req.actor.type === "none") {
throw unauthorized();
}
}
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<string, [string, string]> = {
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"],
};
/** A board page as [label, path], where the path keeps <prefix> for the agent to fill. */
export type BoardControlPage = [label: string, path: string];
/** Budgets are board-only. These pages hold the budget controls behind any route that writes one. */
export const COMPANY_BUDGETS_PAGE: BoardControlPage = ["budgets", "/<prefix>/activity/budgets"];
export function agentBudgetPage(agentId: string): BoardControlPage {
return [`the agent's ${AGENT_PROFILE_TABS.budgets![0]}`, `/<prefix>/agents/${agentId}/${AGENT_PROFILE_TABS.budgets![1]}`];
}
// The board page that holds the control behind a board-only API route, when one
// is known. Paths keep <prefix> for the agent to fill from its own task identifier.
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const BOARD_CONTROL_PAGES: Array<[RegExp, (match: RegExpExecArray, req: Request) => [string, string] | null]> = [
[/^\/(?:companies\/[^/]+\/)?secrets?(?:[-/]|$)/, () => ["company secrets", "/<prefix>/company/settings/secrets"]],
[/^\/companies\/[^/]+\/(?:budgets|budget-incidents|costs)(?:\/|$)/, () => COMPANY_BUDGETS_PAGE],
[/^\/companies\/[^/]+\/(?:prompt-injection-prevention|decision-model)(?:\/|$)/, () => ["connection settings", "/<prefix>/company/settings/connections"]],
[/^\/companies\/[^/]+\/(?:members|invites|join-requests)(?:\/|$)/, () => ["company members", "/<prefix>/company/settings/members"]],
[/^\/companies\/[^/]+\/(?:tools\/)?gateways(?:\/|$)|^\/tool-gateway\//, () => ["tool gateways", "/<prefix>/apps/gateways"]],
[/^\/approvals\/([^/]+)/, match => ["the approval", `/<prefix>/approvals/${match[1]}`]],
[/^\/projects\/([^/]+)\/access-members(?:\/|$)/, (match, req) => {
const projectId = [req.params?.id, match[1]].find((id): id is string => typeof id === "string" && UUID_PATTERN.test(id));
return projectId ? ["the project's access settings", `/<prefix>/projects/${projectId}/configuration`] : null;
}],
[/^\/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]}`, `/<prefix>/agents/${match[1]}/${tab[1]}`] : ["the agent's profile", `/<prefix>/agents/${match[1]}`];
}],
[/^\/companies\/[^/]+(?:\/(?:archive|branding|settings))?\/?$/, () => ["company settings", "/<prefix>/company/settings"]],
];
// Plugin management pages. A plugin id or key segment opens that plugin's settings
// (the page accepts either); a plugin-manager endpoint segment opens the manager.
// An operator can hide plugin management, so link it only while it is visible.
const PLUGIN_REFERENCE = /^[a-z0-9][a-z0-9._-]*$/i;
const PLUGIN_MANAGER_SEGMENTS = new Set(["examples", "install", "tools", "ui-contributions"]);
function pluginControlPage(path: string): [string, string] | null {
const match = /^\/plugins(?:\/([^/]+))?(?:\/|$)/.exec(path);
if (!match || getHiddenSettings().has("instance.plugins")) return null;
const segment = match[1];
const plugin = segment && PLUGIN_REFERENCE.test(segment) && !PLUGIN_MANAGER_SEGMENTS.has(segment) ? segment : null;
return plugin
? ["the plugin's settings", `/<prefix>/company/settings/instance/plugins/${plugin}`]
: ["plugin settings", "/<prefix>/company/settings/instance/plugins"];
}
// Every plugin management route is board-only, so a denial on any one of them
// (often the list an agent reads first) also answers the plugin's other management steps.
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.";
function deniedApiPath(req: Request) {
return (req.originalUrl ?? "").split("?")[0]!.replace(/^\/api(?=\/)/, "");
}
function boardControlPage(req: Request) {
const path = deniedApiPath(req);
const pluginPage = pluginControlPage(path);
if (pluginPage) return pluginPage;
for (const [pattern, page] of BOARD_CONTROL_PAGES) {
const match = pattern.exec(path);
if (match) return page(match, req);
}
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, 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 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.";
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. Stop working on this step and reply now: tell the person that a board user must do it. ${scope}${where}`,
});
}
export const ISSUE_NOT_VISIBLE_CODE = "issue_not_visible";
/**
* The 404 for a task lookup. A deleted task, a private task the caller cannot
* see and another company's task get this same body, so it never says which.
* Agents also learn that the lookup is final, so they reply instead of
* searching other routes for the task.
*/
export function issueNotFoundBody(req: Request): Record<string, unknown> {
if (req.actor.type !== "agent") return { error: "Issue not found" };
return {
error: "Issue not found",
code: ISSUE_NOT_VISIBLE_CODE,
remediation: "No task with this ID or identifier is visible to you. A deleted task, a private task you were not given and a task in another company all get this same answer, so this lookup is final: do not search for the task through other routes, lists, activity, files or hosts, and do not open a similarly named task in its place. Reply now: tell the person you cannot open it here, and that if it still exists they can share it with you from its sharing controls or paste the parts they need.",
};
}
/**
* Board-only check. Pass `controlPage` when the board control lives on a different
* page from the one the route path implies, e.g. a budget field on a general route.
*/
export function assertBoard(req: Request, controlPage?: BoardControlPage) {
if (req.actor.type !== "board") {
throw boardAccessRequired(req, controlPage);
}
}
export function hasBoardOrgAccess(req: Request) {
if (req.actor.type !== "board") {
return false;
}
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
return true;
}
return Array.isArray(req.actor.companyIds) && req.actor.companyIds.length > 0;
}
export function assertBoardOrgAccess(req: Request) {
assertBoard(req);
if (hasBoardOrgAccess(req)) {
return;
}
throw forbidden("Company membership or instance admin access required");
}
export function assertBoardOrAgent(req: Request) {
if (req.actor.type === "agent") {
return;
}
if (req.actor.type === "board") {
assertBoardOrgAccess(req);
return;
}
throw forbidden("Board or agent access required");
}
export function assertInstanceAdmin(req: Request) {
assertBoard(req);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
return;
}
throw forbidden("Instance admin access required");
}
export function assertCompanyAccess(req: Request, companyId: string) {
assertAuthenticated(req);
if (req.actor.type === "agent" && req.actor.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
if (req.actor.type === "agent" && req.actor.onBehalfOfUserId?.trim()) {
const membership = req.actor.onBehalfOfMemberships?.find(
(item) => item.companyId === companyId && item.status === "active",
);
if (!membership) {
throwOrShadowResponsibleUserCompanyAccessDeny(
req,
companyId,
"RESPONSIBLE_USER_UNAVAILABLE",
"Responsible user is unavailable for this company",
);
return;
}
const method = typeof req.method === "string" ? req.method.toUpperCase() : "GET";
const isSafeMethod = ["GET", "HEAD", "OPTIONS"].includes(method);
if (!isSafeMethod && membership.membershipRole === "viewer") {
throwOrShadowResponsibleUserCompanyAccessDeny(
req,
companyId,
"RESPONSIBLE_USER_UNAUTHORIZED",
"Responsible user is not authorized for write access",
);
}
}
if (req.actor.type === "board" && req.actor.source !== "local_implicit") {
const allowedCompanies = req.actor.companyIds ?? [];
if (!allowedCompanies.includes(companyId)) {
throw forbidden("User does not have access to this company");
}
const method = typeof req.method === "string" ? req.method.toUpperCase() : "GET";
const isSafeMethod = ["GET", "HEAD", "OPTIONS"].includes(method);
if (!isSafeMethod && !req.actor.isInstanceAdmin && Array.isArray(req.actor.memberships)) {
const membership = req.actor.memberships.find((item) => item.companyId === companyId);
if (!membership || membership.status !== "active") {
throw forbidden("User does not have active company access");
}
if (membership.membershipRole === "viewer") {
throw forbidden("Viewer access is read-only");
}
}
}
}
/**
* Non-throwing access check for routes that look up a resource by id
* before responding. Prefer this over `assertCompanyAccess` whenever the
* route can reach the access check only after a successful `getById`
* (i.e. after confirming the resource exists).
*
* Using `assertCompanyAccess` in that position leaks resource existence
* across tenants: a 404 means "no such resource" while a 403 means "exists
* in another tenant". Any authenticated user can enumerate IDs and
* distinguish the two responses.
*
* Most routes should use `getAccessibleResource` below, which wraps the
* whole pattern. When composing manually (bespoke not-found responses),
* the shape is:
*
* const issue = await svc.getById(id);
* if (!issue || !hasCompanyAccess(req, issue.companyId)) {
* res.status(404).json({ error: "Issue not found" });
* return;
* }
*
* so both "does not exist" and "exists but cross-tenant" return the same
* 404, removing the oracle.
*
* Note: this intentionally does not replicate the write-path membership
* checks in `assertCompanyAccess` (active membership, viewer read-only).
* Routes that need those checks for authorized tenants should still call
* `assertCompanyAccess` after the 404 gate — the oracle concern is only
* about the existence check.
*
* The company-scope semantics must stay in lockstep with
* `assertCompanyAccess`: in particular, signed-in instance admins do NOT
* get blanket access to companies they are not a member of.
*/
export function hasCompanyAccess(req: Request, companyId: string): boolean {
if (req.actor.type === "none") return false;
if (req.actor.type === "agent") return req.actor.companyId === companyId;
if (req.actor.source === "local_implicit") return true;
return (req.actor.companyIds ?? []).includes(companyId);
}
/**
* Preferred way to fetch a company-scoped resource by id inside a route
* handler. Wraps the two-step pattern described on `hasCompanyAccess` so
* new routes cannot accidentally reintroduce the existence oracle:
*
* - missing resource → 404 `{ error: notFoundMessage }`, returns null
* - exists but cross-tenant → identical 404, returns null
* - accessible → runs `assertCompanyAccess` (write-path
* membership checks on non-safe methods) and returns the resource
*
* Usage:
*
* const goal = await getAccessibleResource(req, res, svc.getById(id), "Goal not found");
* if (!goal) return;
*
* Routes with bespoke not-found behavior (legacy `200 []` contracts,
* audit-logged denials) should still compose `hasCompanyAccess` directly.
*/
export async function getAccessibleResource<T extends { companyId: string }>(
req: Request,
res: Response,
resource: T | null | undefined | Promise<T | null | undefined>,
notFoundMessage: string,
notFoundBody: Record<string, unknown> = { error: notFoundMessage },
): Promise<T | null> {
const resolved = await resource;
if (!resolved || !hasCompanyAccess(req, resolved.companyId)) {
res.status(404).json(notFoundBody);
return null;
}
assertCompanyAccess(req, resolved.companyId);
return resolved;
}
export function getActorInfo(req: Request): (
{
actorType: "agent";
actorId: string;
agentId: string | null;
runId: string | null;
agentApiKeyId: string | null;
actorSource: "agent_key" | "agent_jwt";
}
| {
actorType: "user";
actorId: string;
sessionId: string | null;
agentId: null;
runId: string | null;
agentApiKeyId: null;
actorSource: "local_implicit" | "session" | "board_key" | "mcp_oauth" | "cloud_tenant";
}
) {
assertAuthenticated(req);
if (req.actor.type === "agent") {
const actorSource = req.actor.source === "agent_jwt" ? "agent_jwt" : "agent_key";
return {
actorType: "agent" as const,
actorId: req.actor.agentId ?? "unknown-agent",
agentId: req.actor.agentId ?? null,
runId: req.actor.runId ?? null,
agentApiKeyId: req.actor.keyId ?? null,
actorSource,
};
}
const actorSource =
req.actor.source === "local_implicit" ||
req.actor.source === "board_key" ||
req.actor.source === "cloud_tenant" ||
req.actor.source === "mcp_oauth"
? req.actor.source
: "session";
return {
actorType: "user" as const,
actorId: req.actor.userId ?? "board",
sessionId: req.actor.sessionId ?? null,
agentId: null,
runId: req.actor.runId ?? null,
agentApiKeyId: null,
actorSource,
};
}
/**
* The actor-scoped fields of a secret-binding context, keyed to a caller-supplied
* consumer identity. Structurally matches `SecretConsumerContext` in
* `services/secrets.ts` (whose types are not exported), so the return value slots
* into `resolveAdapterConfigForRuntime`'s 3rd argument
* (`Omit<SecretBindingContext, "configPath">`) unchanged.
*/
export type ActorSecretContext = {
consumerType: SecretBindingTargetType;
consumerId: string;
actorType: "agent" | "user";
actorId: string | null;
actorSource: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "mcp_oauth" | "cloud_tenant";
responsibleUserId: string | null;
};
/**
* Build the actor-scoped portion of a secret-binding context from `req.actor`,
* taking the consumer identity as parameters. The responsible user is derived
* server-side (`req.actor.userId ?? req.actor.onBehalfOfUserId ?? null`) and is
* never request-body-controllable; a `null` result surfaces downstream as the
* intended `responsible_user_missing` loud failure for a required user secret.
*
* `consumerType` is a parameter (not hardcoded `"agent"`) so callers can record an
* honest consumer — `agent` for a persisted agent, `environment`/`system` for a
* prospective config with no persisted consumer.
*
* Never sets `configPath` (the resolver injects it) or `allowedBindingIds`.
*/
export function buildActorSecretContext(
req: Request,
params: { consumerType: SecretBindingTargetType; consumerId: string },
): ActorSecretContext {
const info = getActorInfo(req);
return {
consumerType: params.consumerType,
consumerId: params.consumerId,
actorType: info.actorType,
actorId: info.actorId,
actorSource: info.actorSource,
responsibleUserId: req.actor.userId ?? req.actor.onBehalfOfUserId ?? null,
};
}