Root cause
Injection & unsafe execution introduced
feat: company portability — export/import companies and agents
How AI contributed: Direct introductionPublished Apr 10, 2026 in
paperclipai/paperclipAll 8 findings in this repository
Unauthenticated remote code execution via import authorization bypass. In authenticated-mode Paperclip deployments with default configuration (open signup, no email verification), an attacker can register an account, self-approve a CLI auth challenge to mint a persistent board API key, then call POST /api/companies/import with target.mode=new_company. The import route only called assertBoard() and skipped assertInstanceAdmin for new_company mode (assertInstanceAdmin was not even imported in companies.ts), so any board user could create a company via import and embed a .paperclip.yaml whose process adapter config carries arbitrary command/args. Triggering the agent (wakeup) spawns those commands as the server OS user, achieving RCE. The root failing gate is the missing instance-admin check on the new_company import path.
The import routes trust any board-level actor and skip the instance-admin gate that the sibling company-creation route enforces, so a plain signed-up user can escalate to full import access.
Root cause
feat: company portability — export/import companies and agents
Fix
fix(authz): scope import, approvals, activity, and heartbeat routes (#3315)
AI-assisted fix: ChatGPT/Codex · Dotta
Case facts
@@ -1,14 +1,21 @@ import { Router } from "express"; import type { Db } from "@paperclip/db";-import { createCompanySchema, updateCompanySchema } from "@paperclip/shared";+import {+ companyPortabilityExportSchema,+ companyPortabilityImportSchema,+ companyPortabilityPreviewSchema,+ createCompanySchema,+ updateCompanySchema,+} from "@paperclip/shared"; import { forbidden } from "../errors.js"; import { validate } from "../middleware/validate.js";-import { accessService, companyService, logActivity } from "../services/index.js";-import { assertBoard, assertCompanyAccess } from "./authz.js";+import { accessService, companyPortabilityService, companyService, logActivity } from "../services/index.js";+import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; export function companyRoutes(db: Db) { const router = Router(); const svc = companyService(db);+ const portability = companyPortabilityService(db); const access = accessService(db); router.get("/", async (req, res) => {@@ -48,6 +55,50 @@ export function companyRoutes(db: Db) { res.json(company); }); + router.post("/:companyId/export", validate(companyPortabilityExportSchema), async (req, res) => {+ const companyId = req.params.companyId as string;+ assertCompanyAccess(req, companyId);+ const result = await portability.exportBundle(companyId, req.body);+ res.json(result);+ });++ router.post("/import/preview", validate(companyPortabilityPreviewSchema), async (req, res) => {+ if (req.body.target.mode === "existing_company") {+ assertCompanyAccess(req, req.body.target.companyId);+ } else {+ assertBoard(req);+ }+ const preview = await portability.previewImport(req.body);+ res.json(preview);+ });++ router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => {+ if (req.body.target.mode === "existing_company") {+ assertCompanyAccess(req, req.body.target.companyId);+ } else {+ assertBoard(req);+ }+ const actor = getActorInfo(req);+ const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null);+ await logActivity(db, {+ companyId: result.company.id,+ actorType: actor.actorType,+ actorId: actor.actorId,+ action: "company.imported",+ entityType: "company",+ entityId: result.company.id,+ agentId: actor.agentId,+ runId: actor.runId,+ details: {+ include: req.body.include ?? null,+ agentCount: result.agents.length,+ warningCount: result.warnings.length,+ companyAction: result.company.action,+ },+ });+ res.json(result);+ });+ router.post("/", validate(createCompanySchema), async (req, res) => { assertBoard(req); if (!(req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)) {@@ -0,0 +1,786 @@+import { promises as fs } from "node:fs";+import path from "node:path";+import type { Db } from "@paperclip/db";+import type {+ CompanyPortabilityAgentManifestEntry,+ CompanyPortabilityCollisionStrategy,+ CompanyPortabilityExport,+ CompanyPortabilityExportResult,+ CompanyPortabilityImport,+ CompanyPortabilityImportResult,+ CompanyPortabilityInclude,+ CompanyPortabilityManifest,+ CompanyPortabilityPreview,+ CompanyPortabilityPreviewAgentPlan,+ CompanyPortabilityPreviewResult,+} from "@paperclip/shared";+import { normalizeAgentUrlKey, portabilityManifestSchema } from "@paperclip/shared";+import { notFound, unprocessable } from "../errors.js";+import { accessService } from "./access.js";+import { agentService } from "./agents.js";+import { companyService } from "./companies.js";++const DEFAULT_INCLUDE: CompanyPortabilityInclude = {+ company: true,+ agents: true,+};++const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename";++const SENSITIVE_ENV_KEY_RE =+ /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;++type ResolvedSource = {+ manifest: CompanyPortabilityManifest;+ files: Record<string, string>;+ warnings: string[];+};++type MarkdownDoc = {+ frontmatter: Record<string, unknown>;+ body: string;+};++type ImportPlanInternal = {+ preview: CompanyPortabilityPreviewResult;+ source: ResolvedSource;+ include: CompanyPortabilityInclude;+ collisionStrategy: CompanyPortabilityCollisionStrategy;+ selectedAgents: CompanyPortabilityAgentManifestEntry[];+};++type AgentLike = {+ id: string;+ name: string;+ adapterConfig: Record<string, unknown>;+};++function asString(value: unknown): string | null {+ if (typeof value !== "string") return null;+ const trimmed = value.trim();+ return trimmed.length > 0 ? trimmed : null;+}++function toSafeSlug(input: string, fallback: string) {+ return normalizeAgentUrlKey(input) ?? fallback;+}++function uniqueSlug(base: string, used: Set<string>) {+ if (!used.has(base)) {+ used.add(base);+ return base;+ }+ let idx = 2;+ while (true) {+ const candidate = `${base}-${idx}`;+ if (!used.has(candidate)) {+ used.add(candidate);+ return candidate;+ }+ idx += 1;+ }+}++function uniqueNameBySlug(baseName: string, existingSlugs: Set<string>) {+ const baseSlug = normalizeAgentUrlKey(baseName) ?? "agent";+ if (!existingSlugs.has(baseSlug)) return baseName;+ let idx = 2;+ while (true) {+ const candidateName = `${baseName} ${idx}`;@@ -0,0 +1,112 @@+import { z } from "zod";++export const portabilityIncludeSchema = z+ .object({+ company: z.boolean().optional(),+ agents: z.boolean().optional(),+ })+ .partial();++export const portabilitySecretRequirementSchema = z.object({+ key: z.string().min(1),+ description: z.string().nullable(),+ agentSlug: z.string().min(1).nullable(),+ providerHint: z.string().nullable(),+});++export const portabilityCompanyManifestEntrySchema = z.object({+ path: z.string().min(1),+ name: z.string().min(1),+ description: z.string().nullable(),+ brandColor: z.string().nullable(),+ requireBoardApprovalForNewAgents: z.boolean(),+});++export const portabilityAgentManifestEntrySchema = z.object({+ slug: z.string().min(1),+ name: z.string().min(1),+ path: z.string().min(1),+ role: z.string().min(1),+ title: z.string().nullable(),+ icon: z.string().nullable(),+ capabilities: z.string().nullable(),+ reportsToSlug: z.string().min(1).nullable(),+ adapterType: z.string().min(1),+ adapterConfig: z.record(z.unknown()),+ runtimeConfig: z.record(z.unknown()),+ permissions: z.record(z.unknown()),+ budgetMonthlyCents: z.number().int().nonnegative(),+ metadata: z.record(z.unknown()).nullable(),+});++export const portabilityManifestSchema = z.object({+ schemaVersion: z.number().int().positive(),+ generatedAt: z.string().datetime(),+ source: z+ .object({+ companyId: z.string().uuid(),+ companyName: z.string().min(1),+ })+ .nullable(),+ includes: z.object({+ company: z.boolean(),+ agents: z.boolean(),+ }),+ company: portabilityCompanyManifestEntrySchema.nullable(),+ agents: z.array(portabilityAgentManifestEntrySchema),+ requiredSecrets: z.array(portabilitySecretRequirementSchema).default([]),+});++export const portabilitySourceSchema = z.discriminatedUnion("type", [+ z.object({+ type: z.literal("inline"),+ manifest: portabilityManifestSchema,+ files: z.record(z.string()),+ }),+ z.object({+ type: z.literal("url"),+ url: z.string().url(),+ }),+ z.object({+ type: z.literal("github"),+ url: z.string().url(),+ }),+]);++export const portabilityTargetSchema = z.discriminatedUnion("mode", [+ z.object({+ mode: z.literal("new_company"),+ newCompanyName: z.string().min(1).optional().nullable(),+ }),+ z.object({+ mode: z.literal("existing_company"),+ companyId: z.string().uuid(),+ }),+]);++export const portabilityAgentSelectionSchema = z.union([+ z.literal("all"),+ z.array(z.string().min(1)),@@ -24,7 +24,7 @@ import { logActivity, } from "../services/index.js"; import type { StorageService } from "../storage/types.js";-import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";+import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; export function companyRoutes(db: Db, storage?: StorageService) { const router = Router();@@ -48,6 +48,17 @@ export function companyRoutes(db: Db, storage?: StorageService) { return parsed; } + function assertImportTargetAccess(+ req: Request,+ target: { mode: "new_company" } | { mode: "existing_company"; companyId: string },+ ) {+ if (target.mode === "new_company") {+ assertInstanceAdmin(req);+ return;+ }+ assertCompanyAccess(req, target.companyId);+ }+ async function assertCanUpdateBranding(req: Request, companyId: string) { assertCompanyAccess(req, companyId); if (req.actor.type === "board") return;@@ -160,18 +171,14 @@ export function companyRoutes(db: Db, storage?: StorageService) { router.post("/import/preview", validate(companyPortabilityPreviewSchema), async (req, res) => { assertBoard(req);- if (req.body.target.mode === "existing_company") {- assertCompanyAccess(req, req.body.target.companyId);- }+ assertImportTargetAccess(req, req.body.target); const preview = await portability.previewImport(req.body); res.json(preview); }); router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => { assertBoard(req);- if (req.body.target.mode === "existing_company") {- assertCompanyAccess(req, req.body.target.companyId);- }+ assertImportTargetAccess(req, req.body.target); const actor = getActorInfo(req); const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null); await logActivity(db, {@@ -1,4 +1,4 @@-import { Router } from "express";+import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; import { addApprovalCommentSchema,@@ -34,6 +34,15 @@ export function approvalRoutes(db: Db) { const secretsSvc = secretService(db); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; + async function requireApprovalAccess(req: Request, id: string) {+ const approval = await svc.getById(id);+ if (!approval) {+ return null;+ }+ assertCompanyAccess(req, approval.companyId);+ return approval;+ }+ router.get("/companies/:companyId/approvals", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId);@@ -121,6 +130,10 @@ export function approvalRoutes(db: Db) { router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => { assertBoard(req); const id = req.params.id as string;+ if (!(await requireApprovalAccess(req, id))) {+ res.status(404).json({ error: "Approval not found" });+ return;+ } const { approval, applied } = await svc.approve( id, req.body.decidedByUserId ?? "board",@@ -216,6 +229,10 @@ export function approvalRoutes(db: Db) { router.post("/approvals/:id/reject", validate(resolveApprovalSchema), async (req, res) => { assertBoard(req); const id = req.params.id as string;+ if (!(await requireApprovalAccess(req, id))) {+ res.status(404).json({ error: "Approval not found" });+ return;+ } const { approval, applied } = await svc.reject( id, req.body.decidedByUserId ?? "board",@@ -243,6 +260,10 @@ export function approvalRoutes(db: Db) { async (req, res) => { assertBoard(req); const id = req.params.id as string;+ if (!(await requireApprovalAccess(req, id))) {+ res.status(404).json({ error: "Approval not found" });+ return;+ } const approval = await svc.requestRevision( id, req.body.decidedByUserId ?? "board",@@ -4,7 +4,7 @@ import type { Db } from "@paperclipai/db"; import { validate } from "../middleware/validate.js"; import { activityService } from "../services/activity.js"; import { assertBoard, assertCompanyAccess } from "./authz.js";-import { issueService } from "../services/index.js";+import { heartbeatService, issueService } from "../services/index.js"; import { sanitizeRecord } from "../redaction.js"; const createActivitySchema = z.object({@@ -20,6 +20,7 @@ const createActivitySchema = z.object({ export function activityRoutes(db: Db) { const router = Router(); const svc = activityService(db);+ const heartbeat = heartbeatService(db); const issueSvc = issueService(db); async function resolveIssueByRef(rawId: string) {@@ -46,6 +47,7 @@ export function activityRoutes(db: Db) { router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string;+ assertCompanyAccess(req, companyId); const event = await svc.create({ companyId, ...req.body,@@ -80,6 +82,12 @@ export function activityRoutes(db: Db) { router.get("/heartbeat-runs/:runId/issues", async (req, res) => { const runId = req.params.runId as string;+ const run = await heartbeat.getRun(runId);+ if (!run) {+ res.json([]);+ return;+ }+ assertCompanyAccess(req, run.companyId); const result = await svc.issuesForRun(runId); res.json(result); });AI-assisted change 9b7e1c4677b58f60034be4231f0afb2ad1eda9f27342dd57c49be98fcc7df1c7 · Fix 7beefa5b45394564c00ab431d669a835cffad3a1f0e09802bd9da476af74aa4e