Root cause
Authentication & access control
Add server routes for companies, approvals, costs, and dashboard
AI-assisted change: Claude
How AI contributed: New attack surfacePublished Apr 16, 2026 in
paperclipai/paperclipAll 8 findings in this repository
Cross-tenant IDOR / missing authorization (CWE-639 / CWE-862). GET, POST, DELETE /agents/:id/keys in the Paperclip control-plane API gate only on assertBoard(req) (checks actor.type==='board', no company scoping). A board user constrained to Company A can list, create, or revoke agent API keys for any agent in another company by supplying the victim agent UUID in the URL; POST returns the freshly-minted cleartext token whose companyId column is copied from the victim agent, so the token then authenticates as a full agent principal inside the victim tenant, enabling complete cross-tenant compromise.
The key-management routes never call the helper that enforces company membership, so any board user can read or invalidate credential material for agents in unrelated companies.
Root cause
Add server routes for companies, approvals, costs, and dashboard
AI-assisted change: Claude
Fix
[codex] harden authenticated routes and issue editor reliability (#3741)
AI-assisted fix: ChatGPT/Codex · Dotta
Case facts
@@ -1,52 +1,225 @@ import { Router } from "express"; import type { Db } from "@paperclip/db";-import { createIssueSchema, updateIssueSchema } from "@paperclip/shared";+import {+ addIssueCommentSchema,+ checkoutIssueSchema,+ createIssueSchema,+ updateIssueSchema,+} from "@paperclip/shared"; import { validate } from "../middleware/validate.js";-import { issueService } from "../services/issues.js";+import { issueService, logActivity } from "../services/index.js";+import { assertCompanyAccess, getActorInfo } from "./authz.js"; export function issueRoutes(db: Db) { const router = Router(); const svc = issueService(db); - router.get("/", async (_req, res) => {- const result = await svc.list();+ router.get("/companies/:companyId/issues", async (req, res) => {+ const companyId = req.params.companyId as string;+ assertCompanyAccess(req, companyId);+ const result = await svc.list(companyId, {+ status: req.query.status as string | undefined,+ assigneeAgentId: req.query.assigneeAgentId as string | undefined,+ projectId: req.query.projectId as string | undefined,+ }); res.json(result); }); - router.get("/:id", async (req, res) => {+ router.get("/issues/:id", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; }+ assertCompanyAccess(req, issue.companyId); res.json(issue); }); - router.post("/", validate(createIssueSchema), async (req, res) => {- const issue = await svc.create(req.body);+ router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => {+ const companyId = req.params.companyId as string;+ assertCompanyAccess(req, companyId);++ const actor = getActorInfo(req);+ const issue = await svc.create(companyId, {+ ...req.body,+ createdByAgentId: actor.agentId,+ createdByUserId: actor.actorType === "user" ? actor.actorId : null,+ });++ await logActivity(db, {+ companyId,+ actorType: actor.actorType,+ actorId: actor.actorId,+ agentId: actor.agentId,+ action: "issue.created",+ entityType: "issue",+ entityId: issue.id,+ details: { title: issue.title },+ });+ res.status(201).json(issue); }); - router.patch("/:id", validate(updateIssueSchema), async (req, res) => {+ router.patch("/issues/:id", validate(updateIssueSchema), async (req, res) => { const id = req.params.id as string;+ const existing = await svc.getById(id);+ if (!existing) {+ res.status(404).json({ error: "Issue not found" });+ return;+ }+ assertCompanyAccess(req, existing.companyId);+ const issue = await svc.update(id, req.body); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; }++ const actor = getActorInfo(req);+ await logActivity(db, {+ companyId: issue.companyId,@@ -0,0 +1,123 @@+import { Router } from "express";+import type { Db } from "@paperclip/db";+import { createCostEventSchema, updateBudgetSchema } from "@paperclip/shared";+import { validate } from "../middleware/validate.js";+import { costService, companyService, agentService, logActivity } from "../services/index.js";+import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";++export function costRoutes(db: Db) {+ const router = Router();+ const costs = costService(db);+ const companies = companyService(db);+ const agents = agentService(db);++ router.post("/companies/:companyId/cost-events", validate(createCostEventSchema), async (req, res) => {+ const companyId = req.params.companyId as string;+ assertCompanyAccess(req, companyId);++ if (req.actor.type === "agent" && req.actor.agentId !== req.body.agentId) {+ res.status(403).json({ error: "Agent can only report its own costs" });+ return;+ }++ const event = await costs.createEvent(companyId, {+ ...req.body,+ occurredAt: new Date(req.body.occurredAt),+ });++ const actor = getActorInfo(req);+ await logActivity(db, {+ companyId,+ actorType: actor.actorType,+ actorId: actor.actorId,+ agentId: actor.agentId,+ action: "cost.reported",+ entityType: "cost_event",+ entityId: event.id,+ details: { costCents: event.costCents, model: event.model },+ });++ res.status(201).json(event);+ });++ router.get("/companies/:companyId/costs/summary", async (req, res) => {+ const companyId = req.params.companyId as string;+ assertCompanyAccess(req, companyId);+ const summary = await costs.summary(companyId);+ res.json(summary);+ });++ router.get("/companies/:companyId/costs/by-agent", async (req, res) => {+ const companyId = req.params.companyId as string;+ assertCompanyAccess(req, companyId);+ const rows = await costs.byAgent(companyId);+ res.json(rows);+ });++ router.get("/companies/:companyId/costs/by-project", async (req, res) => {+ const companyId = req.params.companyId as string;+ assertCompanyAccess(req, companyId);+ const rows = await costs.byProject(companyId);+ res.json(rows);+ });++ router.patch("/companies/:companyId/budgets", validate(updateBudgetSchema), async (req, res) => {+ assertBoard(req);+ const companyId = req.params.companyId as string;+ const company = await companies.update(companyId, { budgetMonthlyCents: req.body.budgetMonthlyCents });+ if (!company) {+ res.status(404).json({ error: "Company not found" });+ return;+ }++ await logActivity(db, {+ companyId,+ actorType: "user",+ actorId: req.actor.userId ?? "board",+ action: "company.budget_updated",+ entityType: "company",+ entityId: companyId,+ details: { budgetMonthlyCents: req.body.budgetMonthlyCents },+ });++ res.json(company);+ });++ router.patch("/agents/:agentId/budgets", validate(updateBudgetSchema), async (req, res) => {+ const agentId = req.params.agentId as string;+ const agent = await agents.getById(agentId);+ if (!agent) {@@ -1,52 +1,258 @@ import { Router } from "express"; import type { Db } from "@paperclip/db";-import { createAgentSchema, updateAgentSchema } from "@paperclip/shared";+import {+ createAgentKeySchema,+ createAgentSchema,+ updateAgentSchema,+} from "@paperclip/shared"; import { validate } from "../middleware/validate.js";-import { agentService } from "../services/agents.js";+import { agentService, heartbeatService, logActivity } from "../services/index.js";+import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; export function agentRoutes(db: Db) { const router = Router(); const svc = agentService(db);+ const heartbeat = heartbeatService(db); - router.get("/", async (_req, res) => {- const result = await svc.list();+ router.get("/companies/:companyId/agents", async (req, res) => {+ const companyId = req.params.companyId as string;+ assertCompanyAccess(req, companyId);+ const result = await svc.list(companyId); res.json(result); }); - router.get("/:id", async (req, res) => {+ router.get("/companies/:companyId/org", async (req, res) => {+ const companyId = req.params.companyId as string;+ assertCompanyAccess(req, companyId);+ const tree = await svc.orgForCompany(companyId);+ res.json(tree);+ });++ router.get("/agents/:id", async (req, res) => { const id = req.params.id as string; const agent = await svc.getById(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; }+ assertCompanyAccess(req, agent.companyId); res.json(agent); }); - router.post("/", validate(createAgentSchema), async (req, res) => {- const agent = await svc.create(req.body);+ router.post("/companies/:companyId/agents", validate(createAgentSchema), async (req, res) => {+ const companyId = req.params.companyId as string;+ assertCompanyAccess(req, companyId);++ if (req.actor.type === "agent") {+ assertBoard(req);+ }++ const agent = await svc.create(companyId, {+ ...req.body,+ status: "idle",+ spentMonthlyCents: 0,+ lastHeartbeatAt: null,+ });++ const actor = getActorInfo(req);+ await logActivity(db, {+ companyId,+ actorType: actor.actorType,+ actorId: actor.actorId,+ agentId: actor.agentId,+ action: "agent.created",+ entityType: "agent",+ entityId: agent.id,+ details: { name: agent.name, role: agent.role },+ });+ res.status(201).json(agent); }); - router.patch("/:id", validate(updateAgentSchema), async (req, res) => {+ router.patch("/agents/:id", validate(updateAgentSchema), async (req, res) => { const id = req.params.id as string;+ const existing = await svc.getById(id);+ if (!existing) {+ res.status(404).json({ error: "Agent not found" });+ return;+ }+ assertCompanyAccess(req, existing.companyId);++ if (req.actor.type === "agent" && req.actor.agentId !== id) {@@ -1,4 +1,4 @@-import { Router, type Request } from "express";+import { Router, type Request, type Response } from "express"; import { generateKeyPairSync, randomUUID } from "node:crypto"; import path from "node:path"; import type { Db } from "@paperclipai/db";@@ -46,6 +46,10 @@ import { } from "../services/index.js"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";+import {+ assertNoAgentHostWorkspaceCommandMutation,+ collectAgentAdapterWorkspaceCommandPaths,+} from "./workspace-command-authz.js"; import { detectAdapterModel, findActiveServerAdapter,@@ -231,10 +235,33 @@ export function agentRoutes(db: Db) { return actorAgent; } + async function assertBoardCanManageAgentsForCompany(req: Request, companyId: string) {+ assertBoard(req);+ assertCompanyAccess(req, companyId);+ if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;+ const allowed = await access.canUser(companyId, req.actor.userId, "agents:create");+ if (!allowed) {+ throw forbidden("Missing permission: agents:create");+ }+ }+ async function assertCanReadConfigurations(req: Request, companyId: string) { return assertCanCreateAgentsForCompany(req, companyId); } + async function getAccessibleAgent(req: Request, res: Response, id: string) {+ const agent = await svc.getById(id);+ if (!agent) {+ res.status(404).json({ error: "Agent not found" });+ return null;+ }+ assertCompanyAccess(req, agent.companyId);+ if (req.actor.type === "board") {+ await assertBoardCanManageAgentsForCompany(req, agent.companyId);+ }+ return agent;+ }+ async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) { assertCompanyAccess(req, companyId); if (req.actor.type === "board") {@@ -317,7 +344,10 @@ export function agentRoutes(db: Db) { async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) { assertCompanyAccess(req, targetAgent.companyId);- if (req.actor.type === "board") return;+ if (req.actor.type === "board") {+ await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId);+ return;+ } if (!req.actor.agentId) throw forbidden("Agent authentication required"); const actorAgent = await svc.getById(req.actor.agentId);@@ -339,7 +369,10 @@ export function agentRoutes(db: Db) { async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) { assertCompanyAccess(req, targetAgent.companyId);- if (req.actor.type === "board") return;+ if (req.actor.type === "board") {+ await assertCanReadConfigurations(req, targetAgent.companyId);+ return;+ } if (!req.actor.agentId) throw forbidden("Agent authentication required"); const actorAgent = await svc.getById(req.actor.agentId);@@ -610,19 +643,24 @@ export function agentRoutes(db: Db) { async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) { assertCompanyAccess(req, targetAgent.companyId);- if (req.actor.type === "board") return;- if (!req.actor.agentId) throw forbidden("Agent authentication required");-- const actorAgent = await svc.getById(req.actor.agentId);- if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) {- throw forbidden("Agent key cannot access another company");+ if (req.actor.type !== "board") {+ throw forbidden(+ "Only board-authenticated callers can manage instructions path or bundle configuration",+ ); }@@ -11,7 +11,8 @@ * - Retrieving UI slot contributions for frontend rendering * - Discovering and executing plugin-contributed agent tools *- * All routes require board-level authentication (assertBoard middleware).+ * All routes require board-level authentication, and sensitive instance-wide+ * mutations such as install/upgrade require instance-admin privileges. * * @module server/routes/plugins * @see doc/plugins/PLUGIN_SPEC.md for the full plugin specification@@ -47,7 +48,7 @@ import type { PluginStreamBus } from "../services/plugin-stream-bus.js"; import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js"; import type { ToolRunContext } from "@paperclipai/plugin-sdk"; import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";-import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";+import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; import { validateInstanceConfig } from "../services/plugin-config-validator.js"; /** UI slot declaration extracted from plugin manifest */@@ -583,6 +584,9 @@ export function pluginRoutes( * * Install a plugin from npm or a local filesystem path. *+ * Instance-wide plugin installation is restricted to instance admins because+ * the install flow fetches and inspects package contents on the host.+ * * Request body: * - packageName: npm package name or local path (required) * - version: Target version for npm packages (optional)@@ -601,7 +605,7 @@ export function pluginRoutes( * - `500` — installation succeeded but manifest is missing (indicates a loader bug) */ router.post("/plugins/install", async (req, res) => {- assertBoard(req);+ assertInstanceAdmin(req); const { packageName, version, isLocalPath } = req.body as PluginInstallRequest; // Input validation@@ -1450,6 +1454,9 @@ export function pluginRoutes( * * Upgrade a plugin to a newer version. *+ * Upgrades are restricted to instance admins because they fetch and inspect+ * new package contents on the host before activation.+ * * Request body (optional): * - version: Target version (defaults to latest) *@@ -1461,7 +1468,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.post("/plugins/:pluginId/upgrade", async (req, res) => {- assertBoard(req);+ assertInstanceAdmin(req); const { pluginId } = req.params; const body = req.body as { version?: string } | undefined; const version = body?.version;@@ -47,7 +47,9 @@ import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference, } from "@paperclipai/adapter-utils/server-utils";-import { notFound, unprocessable } from "../errors.js";+import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";+import { findServerAdapter } from "../adapters/index.js";+import { forbidden, notFound, unprocessable } from "../errors.js"; import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js"; import type { StorageService } from "../storage/types.js"; import { accessService } from "./access.js";@@ -62,6 +64,7 @@ import { validateCron } from "./cron.js"; import { issueService } from "./issues.js"; import { projectService } from "./projects.js"; import { routineService } from "./routines.js";+import { secretService } from "./secrets.js"; /** Build OrgNode tree from manifest agent list (slug + reportsToSlug). */ function buildOrgTreeFromManifest(agents: CompanyPortabilityManifest["agents"]): OrgNode[] {@@ -117,6 +120,7 @@ const DEFAULT_INCLUDE: CompanyPortabilityInclude = { }; const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename";+const IMPORT_FORBIDDEN_ADAPTER_TYPES = new Set(["process", "http"]); const execFileAsync = promisify(execFile); let bundledSkillsCommitPromise: Promise<string | null> | null = null; @@ -2747,6 +2751,94 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const projects = projectService(db); const issues = issueService(db); const companySkills = companySkillService(db);+ const secrets = secretService(db);+ const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";++ function assertKnownImportAdapterType(type: string | null | undefined): string {+ const adapterType = typeof type === "string" ? type.trim() : "";+ if (!adapterType) {+ throw unprocessable("Adapter type is required");+ }+ if (!findServerAdapter(adapterType)) {+ throw unprocessable(`Unknown adapter type: ${adapterType}`);+ }+ return adapterType;+ }++ async function assertImportAdapterConfigConstraints(+ companyId: string,+ adapterType: string,+ adapterConfig: Record<string, unknown>,+ ) {+ if (adapterType !== "opencode_local") return;+ const { config: runtimeConfig } = await secrets.resolveAdapterConfigForRuntime(companyId, adapterConfig);+ const runtimeEnv = isPlainRecord(runtimeConfig.env) ? runtimeConfig.env : {};+ try {+ await ensureOpenCodeModelConfiguredAndAvailable({+ model: runtimeConfig.model,+ command: runtimeConfig.command,+ cwd: runtimeConfig.cwd,+ env: runtimeEnv,+ });+ } catch (err) {+ const reason = err instanceof Error ? err.message : String(err);+ throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`);+ }+ }++ async function prepareImportedAgentAdapter(+ companyId: string,+ adapterType: string | null | undefined,+ adapterConfig: Record<string, unknown>,+ desiredSkills: string[],+ mode: ImportMode,+ ) {+ const effectiveAdapterType = assertKnownImportAdapterType(adapterType);+ if (mode === "agent_safe" && IMPORT_FORBIDDEN_ADAPTER_TYPES.has(effectiveAdapterType)) {+ throw forbidden(`Adapter type "${effectiveAdapterType}" is not allowed in safe imports`);+ }+ const nextAdapterConfig = writePaperclipSkillSyncPreference({ ...adapterConfig }, desiredSkills);+ delete nextAdapterConfig.promptTemplate;+ delete nextAdapterConfig.bootstrapPromptTemplate;+ delete nextAdapterConfig.instructionsFilePath;+ delete nextAdapterConfig.instructionsBundleMode;+ delete nextAdapterConfig.instructionsRootPath;+ delete nextAdapterConfig.instructionsEntryFile;+ const normalizedAdapterConfig = await secrets.normalizeAdapterConfigForPersistence(+ companyId,+ nextAdapterConfig,+ { strictMode: strictSecretsMode },+ );+ await assertImportAdapterConfigConstraints(companyId, effectiveAdapterType, normalizedAdapterConfig);AI-assisted change bdfa081690b406f16790f80c711d4b6b3e9273548a3402682db9a902dce990bd · Fix 4ada6747424ee5df8aba2b5705d0517a492b31c9df85ad4deb3e7072a56a78ef