Root cause
Authentication & access control introduced
Add server routes for companies, approvals, costs, and dashboard
AI-assisted change: Claude
How AI contributed: Direct introductionPublished Apr 16, 2026 in
paperclipai/paperclipAll 8 findings in this repository
Cross-tenant broken access control: /api/agents/:id/keys handlers (POST mint, GET list, DELETE revoke) authorize with assertBoard(req) only, never verifying membership in the company that owns the target agent. createApiKey binds the minted plaintext token to the victim agent's companyId, so any signed-up board user with zero memberships can mint a persistent credential for any agent in any company and, via actorMiddleware, read/mutate victim-tenant state.
The key routes check actor type but skip the company-scoping check used by sibling endpoints, and the created key is bound to the victim company, letting a cross-tenant caller take over stored credentials.
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,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) {@@ -0,0 +1,354 @@+import { spawn, type ChildProcess } from "node:child_process";+import { and, eq, inArray } from "drizzle-orm";+import type { Db } from "@paperclip/db";+import { agents, heartbeatRuns } from "@paperclip/db";+import { conflict, notFound } from "../errors.js";+import { logger } from "../middleware/logger.js";++interface RunningProcess {+ child: ChildProcess;+ graceSec: number;+}++const runningProcesses = new Map<string, RunningProcess>();++function parseObject(value: unknown): Record<string, unknown> {+ if (typeof value !== "object" || value === null || Array.isArray(value)) {+ return {};+ }+ return value as Record<string, unknown>;+}++function asString(value: unknown, fallback: string): string {+ return typeof value === "string" && value.length > 0 ? value : fallback;+}++function asNumber(value: unknown, fallback: number): number {+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;+}++function asStringArray(value: unknown): string[] {+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];+}++export function heartbeatService(db: Db) {+ async function getAgent(agentId: string) {+ return db+ .select()+ .from(agents)+ .where(eq(agents.id, agentId))+ .then((rows) => rows[0] ?? null);+ }++ async function setRunStatus(+ runId: string,+ status: string,+ patch?: Partial<typeof heartbeatRuns.$inferInsert>,+ ) {+ return db+ .update(heartbeatRuns)+ .set({ status, ...patch, updatedAt: new Date() })+ .where(eq(heartbeatRuns.id, runId))+ .returning()+ .then((rows) => rows[0] ?? null);+ }++ async function finalizeAgentStatus(agentId: string, ok: boolean) {+ const existing = await getAgent(agentId);+ if (!existing) return;++ if (existing.status === "paused" || existing.status === "terminated") {+ return;+ }++ await db+ .update(agents)+ .set({+ status: ok ? "idle" : "error",+ lastHeartbeatAt: new Date(),+ updatedAt: new Date(),+ })+ .where(eq(agents.id, agentId));+ }++ async function executeHttpRun(runId: string, agentId: string, config: Record<string, unknown>, context: Record<string, unknown>) {+ const url = asString(config.url, "");+ if (!url) throw new Error("HTTP adapter missing url");++ const method = asString(config.method, "POST");+ const timeoutMs = asNumber(config.timeoutMs, 15000);+ const headers = parseObject(config.headers) as Record<string, string>;+ const payloadTemplate = parseObject(config.payloadTemplate);+ const body = { ...payloadTemplate, agentId, runId, context };++ const controller = new AbortController();+ const timer = setTimeout(() => controller.abort(), timeoutMs);++ try {+ const res = await fetch(url, {+ method,@@ -1,38 +1,183 @@-import { eq } from "drizzle-orm";+import { createHash, randomBytes } from "node:crypto";+import { and, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclip/db";-import { agents } from "@paperclip/db";+import { agents, agentApiKeys, heartbeatRuns } from "@paperclip/db";+import { conflict, notFound, unprocessable } from "../errors.js";++function hashToken(token: string) {+ return createHash("sha256").update(token).digest("hex");+}++function createToken() {+ return `pcp_${randomBytes(24).toString("hex")}`;+} export function agentService(db: Db) {+ async function getById(id: string) {+ return db+ .select()+ .from(agents)+ .where(eq(agents.id, id))+ .then((rows) => rows[0] ?? null);+ }++ async function ensureManager(companyId: string, managerId: string) {+ const manager = await getById(managerId);+ if (!manager) throw notFound("Manager not found");+ if (manager.companyId !== companyId) {+ throw unprocessable("Manager must belong to same company");+ }+ return manager;+ }++ async function assertNoCycle(agentId: string, reportsTo: string | null | undefined) {+ if (!reportsTo) return;+ if (reportsTo === agentId) throw unprocessable("Agent cannot report to itself");++ let cursor: string | null = reportsTo;+ while (cursor) {+ if (cursor === agentId) throw unprocessable("Reporting relationship would create cycle");+ const next = await getById(cursor);+ cursor = next?.reportsTo ?? null;+ }+ }+ return {- list: () => db.select().from(agents),+ list: (companyId: string) =>+ db.select().from(agents).where(eq(agents.companyId, companyId)), - getById: (id: string) =>- db- .select()- .from(agents)- .where(eq(agents.id, id))- .then((rows) => rows[0] ?? null),+ getById, - create: (data: typeof agents.$inferInsert) =>- db+ create: async (companyId: string, data: Omit<typeof agents.$inferInsert, "companyId">) => {+ if (data.reportsTo) {+ await ensureManager(companyId, data.reportsTo);+ }++ const created = await db .insert(agents)- .values(data)+ .values({ ...data, companyId }) .returning()- .then((rows) => rows[0]),+ .then((rows) => rows[0]); - update: (id: string, data: Partial<typeof agents.$inferInsert>) =>- db+ return created;+ },++ update: async (id: string, data: Partial<typeof agents.$inferInsert>) => {+ const existing = await getById(id);+ if (!existing) return null;++ if (existing.status === "terminated" && data.status && data.status !== "terminated") {+ throw conflict("Terminated agents cannot be resumed");+ }++ if (data.reportsTo !== undefined) {+ if (data.reportsTo) {@@ -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",+ ); }@@ -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);@@ -96,6 +96,13 @@ describe("MarkdownBody", () => { expect(html).toContain('data-mention-kind="skill"'); }); + it("sanitizes unsafe javascript markdown links", () => {+ const html = renderMarkdown("[click me](javascript:alert(document.cookie))");++ expect(html).toContain('<a href="" rel="noreferrer">click me</a>');+ expect(html).not.toContain("javascript:");+ });+ it("uses soft-break styling by default", () => { const html = renderMarkdown("First line\nSecond line"); AI-assisted change 1a6b0e7041750be8579317e90aeac668eaa4a644ed51a7715642b806f1199ebb · Fix 6d3bf7f3d7b396d6a3f869b6fd65695e3aa601adfbce5f5ca4a6b6bfd1212c59