diff --git a/packages/shared/src/clip-manifest.test.ts b/packages/shared/src/clip-manifest.test.ts new file mode 100644 index 00000000000..6da59662895 --- /dev/null +++ b/packages/shared/src/clip-manifest.test.ts @@ -0,0 +1,362 @@ +import { describe, expect, it } from "vitest"; +import type { CompanyPortabilityManifest } from "./types/company-portability.js"; +import { + buildAgentClipSnapshot, + buildBundleClipSnapshot, + buildRoutineClipSnapshot, + buildSkillClipSnapshot, + buildTeamClipSnapshot, + clipManifestSchema, +} from "./clip-manifest.js"; + +const exportedAt = "2026-05-16T00:00:00.000Z"; + +function fixtureManifest(): CompanyPortabilityManifest { + return { + schemaVersion: 1, + generatedAt: "2026-05-15T00:00:00.000Z", + source: { + companyId: "67f12493-f655-41e7-b5f5-dbe2f44dd7ba", + companyName: "Private Customer Ops", + }, + includes: { + company: true, + agents: true, + projects: true, + issues: true, + skills: true, + }, + company: { + path: "COMPANY.md", + name: "Private Customer Ops", + description: "Routes support for https://localhost:8443/internal and customer jane@example.com", + brandColor: "#111111", + logoPath: null, + attachmentMaxBytes: null, + requireBoardApprovalForNewAgents: true, + feedbackDataSharingEnabled: false, + feedbackDataSharingConsentAt: null, + feedbackDataSharingConsentByUserId: null, + feedbackDataSharingTermsVersion: null, + }, + sidebar: { + agents: ["ceo", "support"], + projects: ["support"], + }, + agents: [ + { + slug: "support", + name: "Support Engineer", + path: "agents/support/AGENTS.md", + skills: ["triage"], + role: "engineer", + title: "Support Engineer", + icon: "code", + capabilities: "Uses bearer token Bearer shh-secret-token for examples", + reportsToSlug: "ceo", + adapterType: "codex_local", + adapterConfig: { + cwd: "/Users/dotta/private-support", + command: "/Users/dotta/.local/bin/codex", + env: { + GH_TOKEN: { + type: "secret_ref", + secretId: "secret-gh-token", + version: "latest", + }, + INTERNAL_URL: { + type: "plain", + value: "http://localhost:3100/private", + }, + SAFE_MODE: { + type: "plain", + value: "true", + }, + }, + }, + runtimeConfig: { + heartbeat: { + intervalSec: 3600, + }, + }, + permissions: { + github: { + issues: { + write: true, + }, + }, + browser: true, + }, + budgetMonthlyCents: 1200, + metadata: { + privateRepoUrl: "https://git.internal/example/private", + }, + }, + { + slug: "ceo", + name: "CEO", + path: "agents/ceo/AGENTS.md", + skills: [], + role: "ceo", + title: "Chief Executive Officer", + icon: null, + capabilities: "Owns strategy", + reportsToSlug: null, + adapterType: "claude_local", + adapterConfig: { + promptTemplate: "You are CEO.", + }, + runtimeConfig: {}, + permissions: {}, + budgetMonthlyCents: 0, + metadata: null, + }, + ], + skills: [ + { + key: "triage", + slug: "triage", + name: "Triage", + path: "skills/triage/SKILL.md", + description: "Classifies inbound support work.", + sourceType: "bundled", + sourceLocator: null, + sourceRef: null, + trustLevel: "company", + compatibility: null, + metadata: null, + fileInventory: [{ path: "skills/triage/SKILL.md", kind: "skill" }], + }, + ], + projects: [ + { + slug: "support", + name: "Support", + path: "projects/support/PROJECT.md", + description: "Private support project", + ownerAgentSlug: "support", + leadAgentSlug: "support", + targetDate: null, + color: null, + status: "in_progress", + env: { + SUPPORT_DSN: { + type: "plain", + value: "postgres://user:pass@localhost:5432/app", + }, + }, + executionWorkspacePolicy: null, + workspaces: [ + { + key: "support-repo", + name: "Support repo", + sourceType: "git", + repoUrl: "https://github.com/paperclipai/paperclip", + repoRef: null, + defaultRef: "main", + visibility: "public", + setupCommand: "pnpm install", + cleanupCommand: null, + metadata: null, + isPrimary: true, + }, + ], + metadata: null, + }, + ], + issues: [ + { + slug: "triage-hourly", + identifier: "PAP-1", + title: "Triage hourly", + path: "tasks/triage-hourly/TASK.md", + projectSlug: "support", + projectWorkspaceKey: "support-repo", + assigneeAgentSlug: "support", + description: "Runs on a webhook and schedule.", + recurring: true, + routine: { + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [ + { + name: "CUSTOMER_EMAIL", + type: "text", + label: "Customer email", + required: false, + defaultValue: "jane@example.com", + options: [], + }, + ], + triggers: [ + { + kind: "webhook", + label: "Inbound webhook", + enabled: true, + cronExpression: null, + timezone: null, + signingMode: "hmac_sha256", + replayWindowSec: 120, + }, + { + kind: "schedule", + label: "Hourly", + enabled: true, + cronExpression: "0 * * * *", + timezone: "UTC", + signingMode: null, + replayWindowSec: null, + }, + ], + }, + legacyRecurrence: null, + status: "todo", + priority: "medium", + labelIds: [], + billingCode: null, + executionWorkspaceSettings: null, + assigneeAdapterOverrides: null, + comments: [ + { + body: "Private comment with sk-testsecret1234567890", + authorType: "agent", + authorAgentSlug: "support", + authorUserId: null, + presentation: null, + metadata: null, + createdAt: exportedAt, + }, + ], + metadata: null, + }, + ], + envInputs: [ + { + key: "GH_TOKEN", + description: "GitHub token", + agentSlug: "support", + projectSlug: null, + kind: "secret", + requirement: "required", + defaultValue: "ghp_supersecretsecret", + portability: "portable", + }, + ], + }; +} + +const clip = { + slug: "support-triage", + revisionNumber: 1, + title: "Support Triage", + summary: "Routes support tickets.", + visibility: "public" as const, + creator: { + profileId: "creator_1", + handle: "paperclip", + displayName: "Paperclip", + }, +}; + +describe("clip manifest foundation", () => { + it("builds a deterministic versioned bundle manifest with checksums and dependencies", () => { + const first = buildBundleClipSnapshot({ + clip, + artifact: { manifest: fixtureManifest(), files: { "COMPANY.md": "# Company" } }, + source: { exportedAt }, + social: { + sourceUrl: "https://paperclip.ing/clips/support-triage", + revisionUrl: "https://paperclip.ing/clips/support-triage/revisions/1", + }, + }); + const second = buildBundleClipSnapshot({ + clip, + artifact: { manifest: fixtureManifest(), files: { "COMPANY.md": "# Company" } }, + source: { exportedAt }, + social: { + sourceUrl: "https://paperclip.ing/clips/support-triage", + revisionUrl: "https://paperclip.ing/clips/support-triage/revisions/1", + }, + }); + + expect(clipManifestSchema.parse(first)).toEqual(first); + expect(first.checksums).toEqual(second.checksums); + expect(first.clip.revisionId).toEqual(second.clip.revisionId); + expect(first.schema).toBe("paperclip.clip/v1"); + expect(first.artifact.version).toBe("agentcompanies/v1-draft"); + expect(first.dependencies.adapters.map((entry) => entry.type)).toEqual(["claude_local", "codex_local"]); + expect(first.dependencies.skills.map((entry) => entry.key)).toEqual(["triage"]); + expect(first.dependencies.secrets.map((entry) => entry.key)).toContain("GH_TOKEN"); + expect(first.dependencies.permissions.map((entry) => entry.capability)).toContain("github.issues.write"); + expect(first.dependencies.runtime).toMatchObject({ + browser: true, + filesystem: "declared", + webhooks: true, + recurringRoutines: true, + }); + expect(first.dependencies.budgetHints.monthlyCents).toBe(1200); + }); + + it("omits secrets, local paths, private URLs, raw comments, and routine webhook signing details", () => { + const manifest = buildRoutineClipSnapshot({ + clip: { ...clip, slug: "support-routine" }, + artifact: { manifest: fixtureManifest() }, + source: { exportedAt }, + routineSlug: "triage-hourly", + }); + const serialized = JSON.stringify(manifest); + + expect(serialized).not.toContain("secret-gh-token"); + expect(serialized).not.toContain("ghp_supersecretsecret"); + expect(serialized).not.toContain("sk-testsecret"); + expect(serialized).not.toContain("/Users/dotta"); + expect(serialized).not.toContain("localhost"); + expect(serialized).not.toContain("https://git.internal"); + expect(serialized).not.toContain("hmac_sha256"); + expect(manifest.artifact.payload.manifest.issues[0]?.comments).toEqual([]); + expect(manifest.artifact.payload.manifest.issues[0]?.routine?.triggers.every((trigger) => !trigger.enabled)).toBe(true); + expect(manifest.security.routinePolicy).toEqual({ + importedTriggersEnabledByDefault: false, + webhookSecretsRegenerated: true, + }); + expect(manifest.security.redactionReport.summary.omitted).toBeGreaterThan(0); + expect(manifest.security.redactionReport.entries.map((entry) => entry.path)).toContain("issues[0].comments"); + }); + + it("scopes agent, team, skill, and routine snapshots to the selected portable object", () => { + const agent = buildAgentClipSnapshot({ + clip: { ...clip, slug: "support-agent" }, + artifact: { manifest: fixtureManifest() }, + source: { exportedAt }, + agentSlug: "support", + }); + const team = buildTeamClipSnapshot({ + clip: { ...clip, slug: "support-team" }, + artifact: { manifest: fixtureManifest() }, + source: { exportedAt }, + teamRootAgentSlug: "ceo", + }); + const skill = buildSkillClipSnapshot({ + clip: { ...clip, slug: "triage-skill" }, + artifact: { manifest: fixtureManifest() }, + source: { exportedAt }, + skillKey: "triage", + }); + const routine = buildRoutineClipSnapshot({ + clip: { ...clip, slug: "triage-routine" }, + artifact: { manifest: fixtureManifest() }, + source: { exportedAt }, + routineSlug: "triage-hourly", + }); + + expect(agent.clip.type).toBe("agent"); + expect(agent.artifact.payload.manifest.agents.map((entry) => entry.slug)).toEqual(["support"]); + expect(team.clip.type).toBe("team"); + expect(team.artifact.payload.manifest.agents.map((entry) => entry.slug)).toEqual(["ceo", "support"]); + expect(skill.clip.type).toBe("skill"); + expect(skill.artifact.payload.manifest.skills.map((entry) => entry.key)).toEqual(["triage"]); + expect(skill.artifact.payload.manifest.agents).toEqual([]); + expect(routine.clip.type).toBe("routine"); + expect(routine.artifact.payload.manifest.issues.map((entry) => entry.slug)).toEqual(["triage-hourly"]); + }); +}); diff --git a/packages/shared/src/clip-manifest.ts b/packages/shared/src/clip-manifest.ts new file mode 100644 index 00000000000..932ee05885e --- /dev/null +++ b/packages/shared/src/clip-manifest.ts @@ -0,0 +1,934 @@ +import { createHash } from "node:crypto"; +import { z } from "zod"; +import type { + CompanyPortabilityAgentManifestEntry, + CompanyPortabilityFileEntry, + CompanyPortabilityManifest, + CompanyPortabilitySkillManifestEntry, +} from "./types/company-portability.js"; + +export const CLIP_MANIFEST_SCHEMA = "paperclip.clip/v1" as const; +export const CLIP_MANIFEST_VERSION = 1 as const; +export const CLIP_ARTIFACT_FORMAT = "agentcompanies" as const; +export const CLIP_ARTIFACT_VERSION = "agentcompanies/v1-draft" as const; + +const SECRET_KEY_PATTERN = /(?:api[_-]?key|auth|authorization|bearer|cookie|credential|dsn|oauth|password|private[_-]?key|secret|signing[_-]?secret|token|webhook[_-]?secret)/i; +const LOCAL_PATH_PATTERN = /(?:^|["'\s])(?:\/Users\/|\/home\/|\/var\/folders\/|[A-Za-z]:\\)/; +const PRIVATE_URL_PATTERN = /\b(?:https?:\/\/)(?:localhost|127\.0\.0\.1|0\.0\.0\.0|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}|[^/\s]+\.local|[^/\s]+\.internal|[^/\s]+\.test)(?::\d+)?(?:[/?#][^\s]*)?/i; +const SECRET_VALUE_PATTERN = /(?:Bearer\s+[A-Za-z0-9._~+/-]+=*|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9_]{16,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/; + +export type ClipManifestType = "team" | "agent" | "skill" | "routine" | "bundle"; +export type ClipManifestVisibility = "private_share" | "unlisted" | "public"; +export type ClipReviewState = "unreviewed" | "automated_checked" | "security_reviewed" | "blocked"; +export type ClipValidationStatus = "not_run" | "passed" | "failed" | "stale"; +export type ClipRedactionOutcome = "allow" | "redact" | "summarize" | "omit"; +export type ClipRuntimeFilesystem = "none" | "declared" | "broad"; + +export interface ClipManifestCreator { + profileId: string | null; + handle: string | null; + displayName: string | null; +} + +export interface ClipManifestClip { + id: string; + slug: string; + type: ClipManifestType; + revisionId: string; + revisionNumber: number; + manifestVersion: typeof CLIP_MANIFEST_VERSION; + title: string; + summary: string; + visibility: ClipManifestVisibility; + creator: ClipManifestCreator | null; +} + +export interface ClipDependencyGraph { + adapters: Array<{ type: string; required: boolean; sourceRefs: string[]; note: string | null }>; + plugins: Array<{ key: string; requirement: "required" | "optional"; sourceRefs: string[]; note: string | null }>; + skills: Array<{ key: string; slug: string; requirement: "required" | "optional"; sourceRefs: string[] }>; + secrets: Array<{ key: string; kind: "secret"; requirement: "required" | "optional"; description: string | null; sourceRefs: string[] }>; + permissions: Array<{ capability: string; reason: string | null; sourceRefs: string[] }>; + runtime: { + localShell: boolean; + browser: boolean; + filesystem: ClipRuntimeFilesystem; + webhooks: boolean; + recurringRoutines: boolean; + }; + workspaces: Array<{ key: string; repoUrlRequired: boolean; pinnedRefRecommended: boolean; sourceRefs: string[] }>; + budgetHints: { + monthlyCents: number; + sourceRefs: string[]; + }; +} + +export interface ClipRedactionReportEntry { + path: string; + outcome: ClipRedactionOutcome; + reason: string; +} + +export interface ClipRedactionReport { + schema: "paperclip.clip.redaction/v1"; + generatedAt: string; + entries: ClipRedactionReportEntry[]; + summary: { + allowed: number; + redacted: number; + summarized: number; + omitted: number; + }; +} + +export interface ClipManifest { + schema: typeof CLIP_MANIFEST_SCHEMA; + clip: ClipManifestClip; + publication: { + source: { + kind: "paperclip_company_object"; + objectType: ClipManifestType; + exportedAt: string; + }; + compatibility: { + paperclip: string | null; + manifest: typeof CLIP_ARTIFACT_VERSION; + }; + }; + artifact: { + format: typeof CLIP_ARTIFACT_FORMAT; + version: typeof CLIP_ARTIFACT_VERSION; + checksum: string; + entrypoint: "COMPANY.md" | "TEAM.md" | "AGENTS.md" | "TASK.md" | "SKILL.md"; + paperclipExtension: ".paperclip.yaml"; + payload: { + manifest: CompanyPortabilityManifest; + }; + }; + dependencies: ClipDependencyGraph; + security: { + redactionReport: ClipRedactionReport; + dangerousCapabilities: string[]; + routinePolicy: { + importedTriggersEnabledByDefault: false; + webhookSecretsRegenerated: true; + }; + reviewState: ClipReviewState; + }; + verification: { + expectedFirstRun: Record | null; + sampleOutputs: Array>; + validationStatus: ClipValidationStatus; + }; + social: { + sourceUrl: string | null; + revisionUrl: string | null; + }; + provenance: { + publishedByProfileId: string | null; + revisionPublishedAt: string; + previousRevisionId: string | null; + }; + checksums: { + artifact: string; + redactionReport: string; + manifest: string; + }; +} + +export interface ClipSnapshotBuilderInput { + clip: { + id?: string | null; + slug: string; + revisionNumber: number; + title: string; + summary: string; + visibility?: ClipManifestVisibility | null; + creator?: ClipManifestCreator | null; + previousRevisionId?: string | null; + }; + artifact: { + manifest: CompanyPortabilityManifest; + files?: Record; + }; + source?: { + exportedAt?: string | null; + paperclipCompatibility?: string | null; + } | null; + social?: { + sourceUrl?: string | null; + revisionUrl?: string | null; + } | null; +} + +export interface ClipScopedSnapshotBuilderInput extends ClipSnapshotBuilderInput { + agentSlug?: string; + teamRootAgentSlug?: string; + skillKey?: string; + skillSlug?: string; + routineSlug?: string; +} + +export const clipTypeSchema = z.enum(["team", "agent", "skill", "routine", "bundle"]); +export const clipManifestCreatorSchema = z.object({ + profileId: z.string().min(1).nullable(), + handle: z.string().min(1).nullable(), + displayName: z.string().min(1).nullable(), +}); + +export const clipRedactionReportSchema = z.object({ + schema: z.literal("paperclip.clip.redaction/v1"), + generatedAt: z.string().datetime(), + entries: z.array(z.object({ + path: z.string().min(1), + outcome: z.enum(["allow", "redact", "summarize", "omit"]), + reason: z.string().min(1), + })), + summary: z.object({ + allowed: z.number().int().nonnegative(), + redacted: z.number().int().nonnegative(), + summarized: z.number().int().nonnegative(), + omitted: z.number().int().nonnegative(), + }), +}); + +export const clipDependencyGraphSchema = z.object({ + adapters: z.array(z.object({ + type: z.string().min(1), + required: z.boolean(), + sourceRefs: z.array(z.string().min(1)), + note: z.string().nullable(), + })), + plugins: z.array(z.object({ + key: z.string().min(1), + requirement: z.enum(["required", "optional"]), + sourceRefs: z.array(z.string().min(1)), + note: z.string().nullable(), + })), + skills: z.array(z.object({ + key: z.string().min(1), + slug: z.string().min(1), + requirement: z.enum(["required", "optional"]), + sourceRefs: z.array(z.string().min(1)), + })), + secrets: z.array(z.object({ + key: z.string().min(1), + kind: z.literal("secret"), + requirement: z.enum(["required", "optional"]), + description: z.string().nullable(), + sourceRefs: z.array(z.string().min(1)), + })), + permissions: z.array(z.object({ + capability: z.string().min(1), + reason: z.string().nullable(), + sourceRefs: z.array(z.string().min(1)), + })), + runtime: z.object({ + localShell: z.boolean(), + browser: z.boolean(), + filesystem: z.enum(["none", "declared", "broad"]), + webhooks: z.boolean(), + recurringRoutines: z.boolean(), + }), + workspaces: z.array(z.object({ + key: z.string().min(1), + repoUrlRequired: z.boolean(), + pinnedRefRecommended: z.boolean(), + sourceRefs: z.array(z.string().min(1)), + })), + budgetHints: z.object({ + monthlyCents: z.number().int().nonnegative(), + sourceRefs: z.array(z.string().min(1)), + }), +}); + +export const clipManifestSchema = z.object({ + schema: z.literal(CLIP_MANIFEST_SCHEMA), + clip: z.object({ + id: z.string().min(1), + slug: z.string().min(1), + type: clipTypeSchema, + revisionId: z.string().min(1), + revisionNumber: z.number().int().positive(), + manifestVersion: z.literal(CLIP_MANIFEST_VERSION), + title: z.string().min(1), + summary: z.string(), + visibility: z.enum(["private_share", "unlisted", "public"]), + creator: clipManifestCreatorSchema.nullable(), + }), + publication: z.object({ + source: z.object({ + kind: z.literal("paperclip_company_object"), + objectType: clipTypeSchema, + exportedAt: z.string().datetime(), + }), + compatibility: z.object({ + paperclip: z.string().nullable(), + manifest: z.literal(CLIP_ARTIFACT_VERSION), + }), + }), + artifact: z.object({ + format: z.literal(CLIP_ARTIFACT_FORMAT), + version: z.literal(CLIP_ARTIFACT_VERSION), + checksum: z.string().regex(/^sha256:[a-f0-9]{64}$/), + entrypoint: z.enum(["COMPANY.md", "TEAM.md", "AGENTS.md", "TASK.md", "SKILL.md"]), + paperclipExtension: z.literal(".paperclip.yaml"), + payload: z.object({ + manifest: z.custom(), + }), + }), + dependencies: clipDependencyGraphSchema, + security: z.object({ + redactionReport: clipRedactionReportSchema, + dangerousCapabilities: z.array(z.string().min(1)), + routinePolicy: z.object({ + importedTriggersEnabledByDefault: z.literal(false), + webhookSecretsRegenerated: z.literal(true), + }), + reviewState: z.enum(["unreviewed", "automated_checked", "security_reviewed", "blocked"]), + }), + verification: z.object({ + expectedFirstRun: z.record(z.string(), z.unknown()).nullable(), + sampleOutputs: z.array(z.record(z.string(), z.unknown())), + validationStatus: z.enum(["not_run", "passed", "failed", "stale"]), + }), + social: z.object({ + sourceUrl: z.string().url().nullable(), + revisionUrl: z.string().url().nullable(), + }), + provenance: z.object({ + publishedByProfileId: z.string().min(1).nullable(), + revisionPublishedAt: z.string().datetime(), + previousRevisionId: z.string().min(1).nullable(), + }), + checksums: z.object({ + artifact: z.string().regex(/^sha256:[a-f0-9]{64}$/), + redactionReport: z.string().regex(/^sha256:[a-f0-9]{64}$/), + manifest: z.string().regex(/^sha256:[a-f0-9]{64}$/), + }), +}); + +export function buildBundleClipSnapshot(input: ClipSnapshotBuilderInput): ClipManifest { + return buildClipSnapshot("bundle", input); +} + +export function buildAgentClipSnapshot(input: ClipScopedSnapshotBuilderInput): ClipManifest { + const agentSlug = input.agentSlug ?? input.artifact.manifest.agents[0]?.slug; + const manifest = agentSlug ? scopeManifestToAgents(input.artifact.manifest, [agentSlug]) : input.artifact.manifest; + return buildClipSnapshot("agent", { ...input, artifact: { ...input.artifact, manifest } }); +} + +export function buildTeamClipSnapshot(input: ClipScopedSnapshotBuilderInput): ClipManifest { + const rootSlug = input.teamRootAgentSlug ?? input.agentSlug ?? input.artifact.manifest.agents.find((agent) => !agent.reportsToSlug)?.slug; + const agentSlugs = rootSlug ? collectAgentSubtreeSlugs(input.artifact.manifest.agents, rootSlug) : []; + const manifest = agentSlugs.length > 0 ? scopeManifestToAgents(input.artifact.manifest, agentSlugs) : input.artifact.manifest; + return buildClipSnapshot("team", { ...input, artifact: { ...input.artifact, manifest } }); +} + +export function buildSkillClipSnapshot(input: ClipScopedSnapshotBuilderInput): ClipManifest { + const manifest = cloneManifest(input.artifact.manifest); + const skill = findSkill(manifest.skills, input.skillKey, input.skillSlug) ?? manifest.skills[0] ?? null; + manifest.agents = []; + manifest.projects = []; + manifest.issues = []; + manifest.skills = skill ? [skill] : []; + manifest.includes = { company: false, agents: false, projects: false, issues: false, skills: manifest.skills.length > 0 }; + return buildClipSnapshot("skill", { ...input, artifact: { ...input.artifact, manifest } }); +} + +export function buildRoutineClipSnapshot(input: ClipScopedSnapshotBuilderInput): ClipManifest { + const manifest = cloneManifest(input.artifact.manifest); + const routine = manifest.issues.find((issue) => ( + issue.slug === input.routineSlug + || issue.identifier === input.routineSlug + || (!input.routineSlug && (issue.recurring || issue.routine)) + )); + const assigneeSlug = routine?.assigneeAgentSlug ?? null; + manifest.issues = routine ? [routine] : []; + manifest.agents = assigneeSlug ? manifest.agents.filter((agent) => agent.slug === assigneeSlug) : []; + manifest.projects = []; + manifest.skills = filterSkillsForAgents(manifest.skills, manifest.agents); + manifest.includes = { + company: false, + agents: manifest.agents.length > 0, + projects: false, + issues: manifest.issues.length > 0, + skills: manifest.skills.length > 0, + }; + return buildClipSnapshot("routine", { ...input, artifact: { ...input.artifact, manifest } }); +} + +export function buildClipSnapshot(type: ClipManifestType, input: ClipSnapshotBuilderInput): ClipManifest { + const exportedAt = input.source?.exportedAt ?? new Date(0).toISOString(); + const sanitized = sanitizeManifest(input.artifact.manifest, exportedAt); + const dependencyGraph = buildDependencyGraph(input.artifact.manifest); + const dangerousCapabilities = buildDangerousCapabilities(dependencyGraph); + const artifactChecksum = sha256Stable({ + manifest: sanitized.manifest, + files: normalizeFilesForChecksum(input.artifact.files ?? {}), + }); + const redactionReportChecksum = sha256Stable(sanitized.redactionReport); + const revisionSeed = sha256Stable({ + schema: CLIP_MANIFEST_SCHEMA, + slug: input.clip.slug, + type, + revisionNumber: input.clip.revisionNumber, + artifactChecksum, + redactionReportChecksum, + }); + const revisionId = `cliprev_${revisionSeed.slice("sha256:".length, "sha256:".length + 16)}`; + + const withoutManifestChecksum: ClipManifest = { + schema: CLIP_MANIFEST_SCHEMA, + clip: { + id: input.clip.id ?? `clip_${normalizeSlug(input.clip.slug)}`, + slug: normalizeSlug(input.clip.slug), + type, + revisionId, + revisionNumber: input.clip.revisionNumber, + manifestVersion: CLIP_MANIFEST_VERSION, + title: input.clip.title, + summary: input.clip.summary, + visibility: input.clip.visibility ?? "unlisted", + creator: input.clip.creator ?? null, + }, + publication: { + source: { + kind: "paperclip_company_object", + objectType: type, + exportedAt, + }, + compatibility: { + paperclip: input.source?.paperclipCompatibility ?? null, + manifest: CLIP_ARTIFACT_VERSION, + }, + }, + artifact: { + format: CLIP_ARTIFACT_FORMAT, + version: CLIP_ARTIFACT_VERSION, + checksum: artifactChecksum, + entrypoint: entrypointForClipType(type), + paperclipExtension: ".paperclip.yaml", + payload: { + manifest: sanitized.manifest, + }, + }, + dependencies: dependencyGraph, + security: { + redactionReport: sanitized.redactionReport, + dangerousCapabilities, + routinePolicy: { + importedTriggersEnabledByDefault: false, + webhookSecretsRegenerated: true, + }, + reviewState: "unreviewed", + }, + verification: { + expectedFirstRun: null, + sampleOutputs: [], + validationStatus: "not_run", + }, + social: { + sourceUrl: input.social?.sourceUrl ?? null, + revisionUrl: input.social?.revisionUrl ?? null, + }, + provenance: { + publishedByProfileId: input.clip.creator?.profileId ?? null, + revisionPublishedAt: exportedAt, + previousRevisionId: input.clip.previousRevisionId ?? null, + }, + checksums: { + artifact: artifactChecksum, + redactionReport: redactionReportChecksum, + manifest: "sha256:0000000000000000000000000000000000000000000000000000000000000000", + }, + }; + + const manifestChecksum = sha256Stable(withoutManifestChecksum); + const result = { + ...withoutManifestChecksum, + checksums: { + ...withoutManifestChecksum.checksums, + manifest: manifestChecksum, + }, + }; + clipManifestSchema.parse(result); + return result; +} + +function sanitizeManifest(source: CompanyPortabilityManifest, generatedAt: string) { + const entries: ClipRedactionReportEntry[] = []; + const manifest = cloneManifest(source); + manifest.generatedAt = generatedAt; + manifest.source = null; + manifest.envInputs = manifest.envInputs.map((envInput, index) => { + if (envInput.defaultValue !== null) { + entries.push({ + path: `envInputs[${index}].defaultValue`, + outcome: "omit", + reason: "environment default values are not published in clip manifests", + }); + } + return { + ...envInput, + defaultValue: null, + description: sanitizeText(envInput.description, `envInputs[${index}].description`, entries), + }; + }); + manifest.company = manifest.company + ? { + ...manifest.company, + name: sanitizeText(manifest.company.name, "company.name", entries) ?? manifest.company.name, + description: sanitizeText(manifest.company.description, "company.description", entries), + } + : null; + manifest.agents = manifest.agents.map((agent, index) => ({ + ...agent, + capabilities: sanitizeText(agent.capabilities, `agents[${index}].capabilities`, entries), + adapterConfig: sanitizeValue(agent.adapterConfig, `agents[${index}].adapterConfig`, entries) as Record, + runtimeConfig: sanitizeValue(agent.runtimeConfig, `agents[${index}].runtimeConfig`, entries) as Record, + permissions: sanitizeValue(agent.permissions, `agents[${index}].permissions`, entries) as Record, + metadata: sanitizeNullableRecord(agent.metadata, `agents[${index}].metadata`, entries), + })); + manifest.projects = manifest.projects.map((project, index) => ({ + ...project, + description: sanitizeText(project.description, `projects[${index}].description`, entries), + env: sanitizeValue(project.env, `projects[${index}].env`, entries) as typeof project.env, + executionWorkspacePolicy: sanitizeNullableRecord(project.executionWorkspacePolicy, `projects[${index}].executionWorkspacePolicy`, entries), + workspaces: project.workspaces.map((workspace, workspaceIndex) => ({ + ...workspace, + repoUrl: shouldOmitString(workspace.repoUrl) + ? recordOmission(`projects[${index}].workspaces[${workspaceIndex}].repoUrl`, "private workspace URLs are not published", entries) + : workspace.repoUrl, + setupCommand: sanitizeText(workspace.setupCommand, `projects[${index}].workspaces[${workspaceIndex}].setupCommand`, entries), + cleanupCommand: sanitizeText(workspace.cleanupCommand, `projects[${index}].workspaces[${workspaceIndex}].cleanupCommand`, entries), + metadata: sanitizeNullableRecord(workspace.metadata, `projects[${index}].workspaces[${workspaceIndex}].metadata`, entries), + })), + metadata: sanitizeNullableRecord(project.metadata, `projects[${index}].metadata`, entries), + })); + manifest.issues = manifest.issues.map((issue, index) => ({ + ...issue, + description: sanitizeText(issue.description, `issues[${index}].description`, entries), + routine: issue.routine + ? { + ...issue.routine, + variables: issue.routine.variables?.map((variable, variableIndex) => ( + sanitizeValue(variable, `issues[${index}].routine.variables[${variableIndex}]`, entries) + )) as typeof issue.routine.variables, + triggers: issue.routine.triggers.map((trigger, triggerIndex) => ({ + ...trigger, + enabled: false, + signingMode: trigger.kind === "webhook" ? null : trigger.signingMode, + replayWindowSec: trigger.kind === "webhook" ? null : trigger.replayWindowSec, + })), + } + : null, + executionWorkspaceSettings: sanitizeNullableRecord(issue.executionWorkspaceSettings, `issues[${index}].executionWorkspaceSettings`, entries), + assigneeAdapterOverrides: sanitizeNullableRecord(issue.assigneeAdapterOverrides, `issues[${index}].assigneeAdapterOverrides`, entries), + comments: [], + metadata: sanitizeNullableRecord(issue.metadata, `issues[${index}].metadata`, entries), + })); + for (const [index, issue] of source.issues.entries()) { + if (issue.comments.length > 0) { + entries.push({ + path: `issues[${index}].comments`, + outcome: "omit", + reason: "raw issue comments are private source-company state", + }); + } + issue.routine?.triggers.forEach((trigger, triggerIndex) => { + if (trigger.enabled) { + entries.push({ + path: `issues[${index}].routine.triggers[${triggerIndex}].enabled`, + outcome: "redact", + reason: "imported routine triggers are disabled by default", + }); + } + if (trigger.kind === "webhook" && (trigger.signingMode || trigger.replayWindowSec !== null)) { + entries.push({ + path: `issues[${index}].routine.triggers[${triggerIndex}]`, + outcome: "omit", + reason: "webhook signing details are regenerated on import", + }); + } + }); + } + + const redactionReport = buildRedactionReport(entries, generatedAt); + return { manifest: sortManifest(manifest), redactionReport }; +} + +function buildDependencyGraph(manifest: CompanyPortabilityManifest): ClipDependencyGraph { + const adapters = new Map; note: string | null }>(); + const plugins = new Map; note: string | null }>(); + const skills = new Map }>(); + const secrets = new Map }>(); + const permissions = new Map }>(); + const workspaces = new Map }>(); + const runtime = { + localShell: false, + browser: false, + filesystem: "none" as ClipRuntimeFilesystem, + webhooks: false, + recurringRoutines: false, + }; + let monthlyCents = 0; + const budgetRefs = new Set(); + + for (const [index, agent] of manifest.agents.entries()) { + const sourceRef = `agents.${agent.slug || index}`; + addMapEntry(adapters, agent.adapterType, { + type: agent.adapterType, + required: true, + sourceRefs: new Set([sourceRef]), + note: null, + }); + monthlyCents += agent.budgetMonthlyCents; + if (agent.budgetMonthlyCents > 0) budgetRefs.add(sourceRef); + for (const skillRef of agent.skills) { + addSkill(skills, skillRef, skillRef, sourceRef); + } + for (const key of collectSecretKeys(agent.adapterConfig)) { + addSecret(secrets, key, "optional", null, sourceRef); + } + for (const permission of collectPermissionCapabilities(agent.permissions)) { + addPermission(permissions, permission, null, sourceRef); + } + if (agent.adapterType === "process" || hasAnyKey(agent.adapterConfig, ["command", "shell", "cwd"])) { + runtime.localShell = true; + } + if (hasAnyKey(agent.permissions, ["browser", "browserAutomation"])) { + runtime.browser = true; + } + if (hasAnyKey(agent.adapterConfig, ["cwd", "workspace", "filesystem", "path"])) { + runtime.filesystem = "declared"; + } + } + + for (const skill of manifest.skills) { + addSkill(skills, skill.key, skill.slug, `skills.${skill.slug}`); + } + + for (const [index, envInput] of manifest.envInputs.entries()) { + if (envInput.kind === "secret") { + addSecret(secrets, envInput.key, envInput.requirement, envInput.description, `envInputs.${index}`); + } + } + + for (const project of manifest.projects) { + for (const key of collectSecretKeys(project.env)) { + addSecret(secrets, key, "optional", null, `projects.${project.slug}.env`); + } + for (const workspace of project.workspaces) { + addMapEntry(workspaces, workspace.key, { + key: workspace.key, + repoUrlRequired: Boolean(workspace.repoUrl), + pinnedRefRecommended: Boolean(workspace.repoUrl && !workspace.repoRef), + sourceRefs: new Set([`projects.${project.slug}.workspaces.${workspace.key}`]), + }); + } + } + + for (const issue of manifest.issues) { + if (issue.recurring || issue.routine) { + runtime.recurringRoutines = true; + } + if (issue.routine?.triggers.some((trigger) => trigger.kind === "webhook")) { + runtime.webhooks = true; + addPermission(permissions, "webhook.receive", "Routine has a webhook trigger placeholder.", `issues.${issue.slug}.routine`); + } + for (const trigger of issue.routine?.triggers ?? []) { + if (trigger.kind === "schedule") { + addPermission(permissions, "routine.schedule", "Routine has a recurring schedule.", `issues.${issue.slug}.routine`); + } + } + } + + return { + adapters: finalizeMap(adapters), + plugins: finalizeMap(plugins), + skills: finalizeMap(skills), + secrets: finalizeMap(secrets), + permissions: finalizeMap(permissions), + runtime, + workspaces: finalizeMap(workspaces), + budgetHints: { + monthlyCents, + sourceRefs: Array.from(budgetRefs).sort(), + }, + }; +} + +function buildDangerousCapabilities(graph: ClipDependencyGraph) { + const capabilities = new Set(); + if (graph.runtime.browser) capabilities.add("browser"); + if (graph.runtime.localShell) capabilities.add("shell"); + if (graph.runtime.filesystem !== "none") capabilities.add("filesystem"); + if (graph.runtime.webhooks) capabilities.add("webhook"); + if (graph.runtime.recurringRoutines) capabilities.add("recurring_routine"); + for (const permission of graph.permissions) { + const root = permission.capability.split(".")[0]; + if (["calendar", "drive", "email", "filesystem", "github", "slack"].includes(root)) { + capabilities.add(root); + } + } + return Array.from(capabilities).sort(); +} + +function sanitizeValue(value: unknown, path: string, entries: ClipRedactionReportEntry[]): unknown { + if (value === null || value === undefined) return value; + if (typeof value === "string") return sanitizeText(value, path, entries); + if (typeof value !== "object") return value; + if (Array.isArray(value)) { + return value.map((entry, index) => sanitizeValue(entry, `${path}[${index}]`, entries)); + } + const record = value as Record; + if (record.type === "secret_ref") { + entries.push({ path, outcome: "omit", reason: "source secret references are replaced with setup inputs" }); + return { type: "secret_input" }; + } + const next: Record = {}; + for (const key of Object.keys(record).sort()) { + const childPath = `${path}.${key}`; + const child = record[key]; + if (SECRET_KEY_PATTERN.test(key) && child !== null && child !== undefined) { + entries.push({ path: childPath, outcome: "omit", reason: "secret-like fields are not published" }); + continue; + } + if (typeof child === "string" && shouldOmitString(child)) { + entries.push({ path: childPath, outcome: "omit", reason: "private local paths, URLs, or credentials are not published" }); + continue; + } + next[key] = sanitizeValue(child, childPath, entries); + } + return next; +} + +function sanitizeText(value: string | null, path: string, entries: ClipRedactionReportEntry[]) { + if (value === null) return null; + if (shouldOmitString(value)) { + entries.push({ path, outcome: "redact", reason: "text contained private paths, URLs, or secret-looking values" }); + return "[redacted]"; + } + return value; +} + +function sanitizeNullableRecord(value: Record | null, path: string, entries: ClipRedactionReportEntry[]) { + return value === null ? null : sanitizeValue(value, path, entries) as Record; +} + +function shouldOmitString(value: string | null) { + return Boolean(value && (SECRET_VALUE_PATTERN.test(value) || LOCAL_PATH_PATTERN.test(value) || PRIVATE_URL_PATTERN.test(value))); +} + +function recordOmission(path: string, reason: string, entries: ClipRedactionReportEntry[]) { + entries.push({ path, outcome: "omit", reason }); + return null; +} + +function buildRedactionReport(entries: ClipRedactionReportEntry[], generatedAt: string): ClipRedactionReport { + const sortedEntries = [...entries].sort((left, right) => left.path.localeCompare(right.path) || left.reason.localeCompare(right.reason)); + return { + schema: "paperclip.clip.redaction/v1", + generatedAt, + entries: sortedEntries, + summary: { + allowed: 0, + redacted: sortedEntries.filter((entry) => entry.outcome === "redact").length, + summarized: sortedEntries.filter((entry) => entry.outcome === "summarize").length, + omitted: sortedEntries.filter((entry) => entry.outcome === "omit").length, + }, + }; +} + +function scopeManifestToAgents(source: CompanyPortabilityManifest, agentSlugs: string[]) { + const selected = new Set(agentSlugs); + const manifest = cloneManifest(source); + manifest.agents = manifest.agents.filter((agent) => selected.has(agent.slug)); + manifest.skills = filterSkillsForAgents(manifest.skills, manifest.agents); + manifest.projects = []; + manifest.issues = manifest.issues.filter((issue) => issue.assigneeAgentSlug ? selected.has(issue.assigneeAgentSlug) : false); + manifest.includes = { + company: false, + agents: manifest.agents.length > 0, + projects: false, + issues: manifest.issues.length > 0, + skills: manifest.skills.length > 0, + }; + return manifest; +} + +function collectAgentSubtreeSlugs(agents: CompanyPortabilityAgentManifestEntry[], rootSlug: string) { + const result = new Set(); + const visit = (slug: string) => { + if (result.has(slug)) return; + result.add(slug); + for (const child of agents.filter((agent) => agent.reportsToSlug === slug)) { + visit(child.slug); + } + }; + visit(rootSlug); + return Array.from(result).sort(); +} + +function filterSkillsForAgents(skills: CompanyPortabilitySkillManifestEntry[], agents: CompanyPortabilityAgentManifestEntry[]) { + const refs = new Set(agents.flatMap((agent) => agent.skills)); + return skills.filter((skill) => refs.has(skill.key) || refs.has(skill.slug)); +} + +function findSkill(skills: CompanyPortabilitySkillManifestEntry[], key?: string, slug?: string) { + return skills.find((skill) => (key && skill.key === key) || (slug && skill.slug === slug)) ?? null; +} + +function sortManifest(manifest: CompanyPortabilityManifest): CompanyPortabilityManifest { + return { + ...manifest, + agents: [...manifest.agents].sort(bySlug), + skills: [...manifest.skills].sort((left, right) => left.key.localeCompare(right.key) || left.slug.localeCompare(right.slug)), + projects: [...manifest.projects].sort(bySlug), + issues: [...manifest.issues].sort(bySlug), + envInputs: [...manifest.envInputs].sort((left, right) => left.key.localeCompare(right.key)), + }; +} + +function normalizeFilesForChecksum(files: Record) { + return Object.fromEntries(Object.entries(files).sort(([left], [right]) => left.localeCompare(right))); +} + +function entrypointForClipType(type: ClipManifestType) { + if (type === "agent") return "AGENTS.md"; + if (type === "skill") return "SKILL.md"; + if (type === "routine") return "TASK.md"; + if (type === "team") return "TEAM.md"; + return "COMPANY.md"; +} + +function collectSecretKeys(value: unknown): string[] { + const keys = new Set(); + const visit = (entry: unknown, keyHint: string | null) => { + if (entry === null || entry === undefined) return; + if (typeof entry !== "object") { + if (keyHint && SECRET_KEY_PATTERN.test(keyHint)) keys.add(keyHint); + return; + } + if (Array.isArray(entry)) { + entry.forEach((item) => visit(item, keyHint)); + return; + } + const record = entry as Record; + if (record.type === "secret_ref") { + if (keyHint) keys.add(keyHint); + return; + } + for (const [key, child] of Object.entries(record)) { + if (SECRET_KEY_PATTERN.test(key)) keys.add(key); + visit(child, key); + } + }; + visit(value, null); + return Array.from(keys).sort(); +} + +function collectPermissionCapabilities(value: unknown): string[] { + const capabilities = new Set(); + const visit = (entry: unknown, path: string[]) => { + if (entry === null || entry === undefined) return; + if (typeof entry === "boolean") { + if (entry && path.length > 0) capabilities.add(path.join(".")); + return; + } + if (typeof entry === "string") { + if (entry === "true" || entry === "allow" || entry === "write") capabilities.add(path.join(".")); + return; + } + if (typeof entry !== "object" || Array.isArray(entry)) return; + for (const [key, child] of Object.entries(entry as Record)) { + visit(child, [...path, key]); + } + }; + visit(value, []); + return Array.from(capabilities).sort(); +} + +function hasAnyKey(value: unknown, keys: string[]) { + if (!value || typeof value !== "object") return false; + const wanted = new Set(keys); + const visit = (entry: unknown): boolean => { + if (!entry || typeof entry !== "object") return false; + if (Array.isArray(entry)) return entry.some(visit); + return Object.entries(entry as Record).some(([key, child]) => wanted.has(key) || visit(child)); + }; + return visit(value); +} + +function addSecret( + secrets: Map }>, + key: string, + requirement: "required" | "optional", + description: string | null, + sourceRef: string, +) { + addMapEntry(secrets, key, { key, kind: "secret", requirement, description, sourceRefs: new Set([sourceRef]) }); +} + +function addSkill( + skills: Map }>, + key: string, + slug: string, + sourceRef: string, +) { + addMapEntry(skills, key, { key, slug, requirement: "required", sourceRefs: new Set([sourceRef]) }); +} + +function addPermission( + permissions: Map }>, + capability: string, + reason: string | null, + sourceRef: string, +) { + addMapEntry(permissions, capability, { capability, reason, sourceRefs: new Set([sourceRef]) }); +} + +function addMapEntry }>(map: Map, key: string, value: T) { + const existing = map.get(key); + if (!existing) { + map.set(key, value); + return; + } + for (const sourceRef of value.sourceRefs) { + existing.sourceRefs.add(sourceRef); + } +} + +function finalizeMap }>(map: Map) { + return Array.from(map.values()) + .map((entry) => ({ ...entry, sourceRefs: Array.from(entry.sourceRefs).sort() })) + .sort((left, right) => stableStringify(left).localeCompare(stableStringify(right))); +} + +function bySlug(left: T, right: T) { + return left.slug.localeCompare(right.slug); +} + +function normalizeSlug(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") || "clip"; +} + +function cloneManifest(manifest: CompanyPortabilityManifest): CompanyPortabilityManifest { + return JSON.parse(JSON.stringify(manifest)) as CompanyPortabilityManifest; +} + +function sha256Stable(value: unknown) { + return `sha256:${createHash("sha256").update(stableStringify(value)).digest("hex")}`; +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; + return `{${Object.keys(value as Record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify((value as Record)[key])}`).join(",")}}`; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8dd130c268e..a64af253f18 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -652,6 +652,39 @@ export { } from "./validators/sidebar-preferences.js"; export { workspaceRuntimeControlTargetSchema } from "./validators/execution-workspace.js"; + +export { + CLIP_ARTIFACT_FORMAT, + CLIP_ARTIFACT_VERSION, + CLIP_MANIFEST_SCHEMA, + CLIP_MANIFEST_VERSION, + buildAgentClipSnapshot, + buildBundleClipSnapshot, + buildClipSnapshot, + buildRoutineClipSnapshot, + buildSkillClipSnapshot, + buildTeamClipSnapshot, + clipDependencyGraphSchema, + clipManifestCreatorSchema, + clipManifestSchema, + clipRedactionReportSchema, + clipTypeSchema, + type ClipDependencyGraph, + type ClipManifest, + type ClipManifestClip, + type ClipManifestCreator, + type ClipManifestType, + type ClipManifestVisibility, + type ClipRedactionOutcome, + type ClipRedactionReport, + type ClipRedactionReportEntry, + type ClipReviewState, + type ClipRuntimeFilesystem, + type ClipScopedSnapshotBuilderInput, + type ClipSnapshotBuilderInput, + type ClipValidationStatus, +} from "./clip-manifest.js"; + export { findWorkspaceCommandDefinition, listWorkspaceCommandDefinitions,