Files
PaperClipAI/server/src/middleware/error-handler.ts
T
DottaandClaude Opus 5.5 43fe87795a fix(authz): make board-only denials final and actionable for agents (#20151)
## 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 `<prefix>` 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 <noreply@anthropic.com>
2026-11-08 21:28:52 -06:00

374 lines
13 KiB
TypeScript

import { AgentLifecycleConflict } from "../modules/agent-lifecycle/index.js";
import type { Request, Response, NextFunction } from "express";
import type { Db } from "@paperclipai/db";
import { ZodError } from "zod";
import { HttpError } from "../errors.js";
import { trackErrorHandlerCrash } from "@paperclipai/shared/telemetry";
import { getTelemetryClient } from "../telemetry.js";
import { captureException } from "../sentry.js";
import { COMPANY_IMPORT_API_PATH } from "../routes/company-import-paths.js";
import { logger } from "./logger.js";
import {
isSecretSensitiveHttpRequest,
secretSensitiveRouteFamily,
} from "./http-log-policy.js";
import { SecretSensitiveRequestError } from "./secret-sensitive-request-error.js";
import {
collectSensitiveStringValues,
redactSensitiveValueOccurrences,
} from "./redact-sensitive.js";
import { recordResponsibleUserDenialOnActiveRun } from "../services/responsible-user-denial-run-outcomes.js";
import { isObjectJournalBusyError } from "../services/object-byte-journal.js";
import { isExpectedMcpConnectionFailure } from "../services/mcp-connection-failure.js";
import { isEndedDbPoolError } from "../database-retry.js";
import { ToolGatewayHttpError } from "../services/tool-gateway.js";
export interface ErrorContext {
error: {
message: string;
stack?: string;
name?: string;
details?: unknown;
raw?: unknown;
};
method: string;
url: string;
reqBody?: unknown;
reqParams?: unknown;
reqQuery?: unknown;
}
function isRedactedSkillPolicyDenial(details: Record<string, unknown> | null) {
return details?.code === "skill_policy_denied";
}
function readZodIssues(err: unknown): unknown[] | null {
if (err instanceof ZodError) return err.issues;
if (
!err ||
typeof err !== "object" ||
(err as { name?: unknown }).name !== "ZodError"
)
return null;
const issues = (err as { issues?: unknown }).issues;
return Array.isArray(issues) ? issues : null;
}
function attachErrorContext(
req: Request,
res: Response,
payload: ErrorContext["error"],
rawError?: Error,
) {
(res as any).__errorContext = {
error: payload,
method: req.method,
url: req.originalUrl,
reqBody: req.body,
reqParams: req.params,
reqQuery: req.query,
} satisfies ErrorContext;
if (rawError) {
(res as any).err = rawError;
}
}
function sanitizeSecretSensitiveError(req: Request, error: Error): Error {
const routeFamily = secretSensitiveRouteFamily(req.method, req.originalUrl);
if (!routeFamily) return error;
return new SecretSensitiveRequestError(error, {
routeFamily,
method: req.method,
routePath: (req.route as { path?: unknown } | undefined)?.path,
});
}
function sanitizeSecretSensitiveResponse(
req: Request,
value: unknown,
): unknown {
if (!isSecretSensitiveHttpRequest(req.method, req.originalUrl)) return value;
return redactSensitiveValueOccurrences(
value,
collectSensitiveStringValues(req.body),
);
}
/** Report a server-side crash to every error sink. */
function reportCrash(error: Error, req: Request, reportToSentry = true): void {
const tc = getTelemetryClient();
if (tc) trackErrorHandlerCrash(tc, { errorCode: error.name });
if (reportToSentry) {
if (req.actor?.type === "agent" && req.actor.runId && req.actor.companyId && req.actor.agentId) {
captureException(error, { runId: req.actor.runId, companyId: req.actor.companyId, agentId: req.actor.agentId });
} else captureException(error);
}
}
/** Set by the orderly shutdown just before it ends the database pools. */
function isDatabaseClosing(req: Request): boolean {
const locals = req.app?.locals as { paperclipDatabaseClosing?: boolean } | undefined;
return locals?.paperclipDatabaseClosing === true;
}
function getPaperclipDb(req: Request): Db | null {
const locals = req.app?.locals as { paperclipDb?: Db; db?: Db } | undefined;
return locals?.paperclipDb ?? locals?.db ?? null;
}
function recordResponsibleUserDenialFromHttpError(
req: Request,
details: Record<string, unknown> | null,
) {
if (req.actor?.type !== "agent") return;
const db = getPaperclipDb(req);
if (!db) return;
void recordResponsibleUserDenialOnActiveRun(db, {
runId: req.actor.runId ?? null,
agentId: req.actor.agentId ?? null,
companyId: req.actor.companyId ?? null,
code: details?.code,
}).catch((recordErr) => {
logger.warn(
{
err: recordErr,
runId: req.actor?.runId ?? null,
agentId:
req.actor?.type === "agent" ? (req.actor.agentId ?? null) : null,
},
"failed to record responsible-user denial on heartbeat run",
);
});
}
/**
* The tool gateway rejects requests with its own error type. A route that lets
* one reach this handler (such as approving a tool review from a task card)
* would otherwise turn an expected rejection, for example a 409 asking for a
* new review, into a 500 crash report. Gateway server failures stay on the
* crash path so their provider text is not exposed.
*/
function clientToolGatewayErrorAsHttpError(err: unknown): unknown {
if (!(err instanceof ToolGatewayHttpError) || err.status < 400 || err.status >= 500) return err;
const mapped = new HttpError(err.status, err.message, { ...err.details, code: err.reasonCode });
mapped.stack = err.stack;
return mapped;
}
export function errorHandler(
thrown: unknown,
req: Request,
res: Response,
_next: NextFunction,
) {
const err = clientToolGatewayErrorAsHttpError(thrown);
if (err instanceof AgentLifecycleConflict) { res.status(409).json({ error: err.message }); return; }
if (err instanceof HttpError) {
const details =
err.details &&
typeof err.details === "object" &&
!Array.isArray(err.details)
? (err.details as Record<string, unknown>)
: null;
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",
"grant_audience_denied",
"grant_revoked",
"needs_reauthorization",
"installation_required",
"connection_not_installed",
"subject_not_permitted",
"standing_delegation_required",
"grant_owner_membership_inactive",
]).has(typeof details?.code === "string" ? details.code : "");
const responseDetailsValue = sanitizeSecretSensitiveResponse(
req,
err.details,
);
const responseDetails =
responseDetailsValue &&
typeof responseDetailsValue === "object" &&
!Array.isArray(responseDetailsValue)
? (responseDetailsValue as Record<string, unknown>)
: null;
recordResponsibleUserDenialFromHttpError(req, details);
if (err.status >= 500) {
const reportableError = sanitizeSecretSensitiveError(req, err);
attachErrorContext(
req,
res,
isSecretSensitiveHttpRequest(req.method, req.originalUrl)
? { message: reportableError.message, name: reportableError.name }
: {
message: err.message,
stack: err.stack,
name: err.name,
details: err.details,
},
reportableError,
);
reportCrash(reportableError, req, !isExpectedMcpConnectionFailure(err));
}
const secretSensitiveServerError =
err.status >= 500 &&
isSecretSensitiveHttpRequest(req.method, req.originalUrl);
res.status(err.status).json(
secretSensitiveServerError
? { error: "Internal server error" }
: {
error: sanitizeSecretSensitiveResponse(req, err.message),
...(typeof responseDetails?.code === "string"
? { code: responseDetails.code }
: {}),
...(redactedSkillPolicyDenial &&
typeof responseDetails?.reason === "string"
? { reason: responseDetails.reason }
: {}),
...(workspaceRepairPreconditionFailure &&
typeof responseDetails?.reason === "string"
? { reason: responseDetails.reason }
: {}),
...(workspaceRepairPreconditionFailure &&
typeof responseDetails?.repairPhase === "string"
? { repairPhase: responseDetails.repairPhase }
: {}),
...(typeof responseDetails?.remediation === "string" ||
(structuredConnectionError &&
responseDetails?.remediation &&
typeof responseDetails.remediation === "object")
? { remediation: responseDetails.remediation }
: {}),
...(structuredConnectionError && responseDetails?.connection
? { connection: responseDetails.connection }
: {}),
...(structuredConnectionError && responseDetails?.subject
? { subject: responseDetails.subject }
: {}),
...(structuredConnectionError &&
typeof responseDetails?.grantId === "string"
? { grantId: responseDetails.grantId }
: {}),
...(!redactedSkillPolicyDenial &&
!workspaceRepairPreconditionFailure &&
!boardAccessDenial &&
responseDetailsValue
? { details: responseDetailsValue }
: {}),
},
);
return;
}
const zodIssues = readZodIssues(err);
if (zodIssues) {
res.status(400).json({
error: "Validation error",
details: sanitizeSecretSensitiveResponse(req, zodIssues),
});
return;
}
// Only body-parser's malformed-JSON errors are client input failures.
// Parser messages can quote request bytes; return a constant response and
// keep the raw error out of crash reporting and HTTP error context.
if (
err instanceof SyntaxError &&
"status" in err && err.status === 400 &&
"type" in err && err.type === "entity.parse.failed"
) {
res.status(400).json({ error: "Invalid JSON body" });
return;
}
const rootError = err instanceof Error ? err : new Error(String(err));
// The client tore down the connection mid-request (closed tab, dropped
// mobile network, cancelled upload): Node surfaces it as `Error: aborted`
// with ECONNRESET. There is no server fault to report and nobody left to
// answer, so skip the error sinks and just close out the response.
if (
rootError.message === "aborted" &&
(rootError as NodeJS.ErrnoException).code === "ECONNRESET"
) {
if (!res.headersSent) res.status(499);
res.end();
return;
}
// A route that cancels its work when the response closes (for example a
// storage read) rejects with AbortError after the client has gone. That
// rejection is the cancellation, not a server fault. An AbortError while
// the client is still connected is still reported.
if (rootError.name === "AbortError" && res.destroyed && !res.writableFinished) {
if (!res.headersSent) res.status(499);
res.end();
return;
}
// Shutdown drains the HTTP listener before it ends the pool, but a handler
// whose client already disconnected (or whose socket the drain timeout
// closed) keeps running. Its next query then fails with CONNECTION_ENDED
// against the pool this process deliberately ended. That is shutdown, not a
// server fault, so skip the error sinks. Any other error during shutdown is
// still reported.
if (isDatabaseClosing(req) && isEndedDbPoolError(rootError)) {
if (!res.headersSent) {
res.status(503).json({ error: "Server is shutting down" });
} else {
res.end();
}
return;
}
if (isObjectJournalBusyError(rootError)) {
if (!res.headersSent) {
res.set("Retry-After", "2");
res.status(503).json({ error: rootError.message, code: rootError.code });
} else {
res.end();
}
return;
}
const reportableError = sanitizeSecretSensitiveError(req, rootError);
attachErrorContext(
req,
res,
isSecretSensitiveHttpRequest(req.method, req.originalUrl)
? { message: reportableError.message, name: reportableError.name }
: err instanceof Error
? { message: err.message, stack: err.stack, name: err.name }
: {
message: String(err),
raw: err,
stack: rootError.stack,
name: rootError.name,
},
reportableError,
);
reportCrash(reportableError, req);
res.status(500).json({
error: "Internal server error",
...(shouldExposeTrustedCloudTenantImportError(req)
? { message: rootError.message }
: {}),
});
}
function shouldExposeTrustedCloudTenantImportError(req: Request) {
return (
req.actor?.source === "cloud_tenant" &&
req.method === "POST" &&
req.originalUrl.split("?")[0] === COMPANY_IMPORT_API_PATH
);
}