mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 07:55:47 +01:00
935 lines
35 KiB
TypeScript
935 lines
35 KiB
TypeScript
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<string, unknown> | null;
|
|
sampleOutputs: Array<Record<string, unknown>>;
|
|
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<string, CompanyPortabilityFileEntry>;
|
|
};
|
|
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<CompanyPortabilityManifest>(),
|
|
}),
|
|
}),
|
|
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<string, unknown>,
|
|
runtimeConfig: sanitizeValue(agent.runtimeConfig, `agents[${index}].runtimeConfig`, entries) as Record<string, unknown>,
|
|
permissions: sanitizeValue(agent.permissions, `agents[${index}].permissions`, entries) as Record<string, unknown>,
|
|
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<string, { type: string; required: boolean; sourceRefs: Set<string>; note: string | null }>();
|
|
const plugins = new Map<string, { key: string; requirement: "required" | "optional"; sourceRefs: Set<string>; note: string | null }>();
|
|
const skills = new Map<string, { key: string; slug: string; requirement: "required" | "optional"; sourceRefs: Set<string> }>();
|
|
const secrets = new Map<string, { key: string; kind: "secret"; requirement: "required" | "optional"; description: string | null; sourceRefs: Set<string> }>();
|
|
const permissions = new Map<string, { capability: string; reason: string | null; sourceRefs: Set<string> }>();
|
|
const workspaces = new Map<string, { key: string; repoUrlRequired: boolean; pinnedRefRecommended: boolean; sourceRefs: Set<string> }>();
|
|
const runtime = {
|
|
localShell: false,
|
|
browser: false,
|
|
filesystem: "none" as ClipRuntimeFilesystem,
|
|
webhooks: false,
|
|
recurringRoutines: false,
|
|
};
|
|
let monthlyCents = 0;
|
|
const budgetRefs = new Set<string>();
|
|
|
|
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<string>();
|
|
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<string, unknown>;
|
|
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<string, unknown> = {};
|
|
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<string, unknown> | null, path: string, entries: ClipRedactionReportEntry[]) {
|
|
return value === null ? null : sanitizeValue(value, path, entries) as Record<string, unknown>;
|
|
}
|
|
|
|
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<string>();
|
|
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<string, CompanyPortabilityFileEntry>) {
|
|
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<string>();
|
|
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<string, unknown>;
|
|
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<string>();
|
|
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<string, unknown>)) {
|
|
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<string, unknown>).some(([key, child]) => wanted.has(key) || visit(child));
|
|
};
|
|
return visit(value);
|
|
}
|
|
|
|
function addSecret(
|
|
secrets: Map<string, { key: string; kind: "secret"; requirement: "required" | "optional"; description: string | null; sourceRefs: Set<string> }>,
|
|
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<string, { key: string; slug: string; requirement: "required" | "optional"; sourceRefs: Set<string> }>,
|
|
key: string,
|
|
slug: string,
|
|
sourceRef: string,
|
|
) {
|
|
addMapEntry(skills, key, { key, slug, requirement: "required", sourceRefs: new Set([sourceRef]) });
|
|
}
|
|
|
|
function addPermission(
|
|
permissions: Map<string, { capability: string; reason: string | null; sourceRefs: Set<string> }>,
|
|
capability: string,
|
|
reason: string | null,
|
|
sourceRef: string,
|
|
) {
|
|
addMapEntry(permissions, capability, { capability, reason, sourceRefs: new Set([sourceRef]) });
|
|
}
|
|
|
|
function addMapEntry<T extends { sourceRefs: Set<string> }>(map: Map<string, T>, 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<T extends { sourceRefs: Set<string> }>(map: Map<string, T>) {
|
|
return Array.from(map.values())
|
|
.map((entry) => ({ ...entry, sourceRefs: Array.from(entry.sourceRefs).sort() }))
|
|
.sort((left, right) => stableStringify(left).localeCompare(stableStringify(right)));
|
|
}
|
|
|
|
function bySlug<T extends { slug: string }>(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<string, unknown>).sort().map((key) => `${JSON.stringify(key)}:${stableStringify((value as Record<string, unknown>)[key])}`).join(",")}}`;
|
|
}
|