Restrict company imports to GitHub and zip packages

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-03-16 09:52:16 -05:00
parent 0b829ea20b
commit 4a5aba5bac
6 changed files with 163 additions and 94 deletions

View File

@@ -120,10 +120,6 @@ export type CompanyPortabilitySource =
rootPath?: string | null;
files: Record<string, string>;
}
| {
type: "url";
url: string;
}
| {
type: "github";
url: string;

View File

@@ -123,10 +123,6 @@ export const portabilitySourceSchema = z.discriminatedUnion("type", [
rootPath: z.string().min(1).optional().nullable(),
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(),

View File

@@ -1380,33 +1380,6 @@ export function companyPortabilityService(db: Db) {
);
}
if (source.type === "url") {
const normalizedUrl = source.url.trim();
const companyUrl = normalizedUrl.endsWith(".md")
? normalizedUrl
: new URL("COMPANY.md", normalizedUrl.endsWith("/") ? normalizedUrl : `${normalizedUrl}/`).toString();
const companyMarkdown = await fetchText(companyUrl);
const files: Record<string, string> = {
"COMPANY.md": companyMarkdown,
};
const paperclipYaml = await fetchOptionalText(
new URL(".paperclip.yaml", companyUrl).toString(),
).catch(() => null);
if (paperclipYaml) {
files[".paperclip.yaml"] = paperclipYaml;
}
const companyDoc = parseFrontmatterMarkdown(companyMarkdown);
const includeEntries = readIncludeEntries(companyDoc.frontmatter);
for (const includeEntry of includeEntries) {
const includePath = normalizePortablePath(includeEntry.path);
if (!includePath.endsWith(".md")) continue;
const includeUrl = new URL(includeEntry.path, companyUrl).toString();
files[includePath] = await fetchText(includeUrl);
}
return buildManifestFromPackageFiles(files);
}
const parsed = parseGitHubSourceUrl(source.url);
let ref = parsed.ref;
const warnings: string[] = [];

View File

@@ -1,7 +1,7 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { createZipArchive } from "./zip";
import { createZipArchive, readZipArchive } from "./zip";
function readUint16(bytes: Uint8Array, offset: number) {
return bytes[offset]! | (bytes[offset + 1]! << 8);
@@ -50,4 +50,24 @@ describe("createZipArchive", () => {
expect(readUint16(archive, endOffset + 8)).toBe(2);
expect(readUint16(archive, endOffset + 10)).toBe(2);
});
it("reads a Paperclip zip archive back into rootPath and file contents", () => {
const archive = createZipArchive(
{
"COMPANY.md": "# Company\n",
"agents/ceo/AGENTS.md": "# CEO\n",
".paperclip.yaml": "schema: paperclip/v1\n",
},
"paperclip-demo",
);
expect(readZipArchive(archive)).toEqual({
rootPath: "paperclip-demo",
files: {
"COMPANY.md": "# Company\n",
"agents/ceo/AGENTS.md": "# CEO\n",
".paperclip.yaml": "schema: paperclip/v1\n",
},
});
});
});

View File

@@ -1,4 +1,5 @@
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
const crcTable = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
@@ -37,6 +38,19 @@ function writeUint32(target: Uint8Array, offset: number, value: number) {
target[offset + 3] = (value >>> 24) & 0xff;
}
function readUint16(source: Uint8Array, offset: number) {
return source[offset]! | (source[offset + 1]! << 8);
}
function readUint32(source: Uint8Array, offset: number) {
return (
source[offset]! |
(source[offset + 1]! << 8) |
(source[offset + 2]! << 16) |
(source[offset + 3]! << 24)
) >>> 0;
}
function getDosDateTime(date: Date) {
const year = Math.min(Math.max(date.getFullYear(), 1980), 2107);
const month = date.getMonth() + 1;
@@ -62,6 +76,84 @@ function concatChunks(chunks: Uint8Array[]) {
return archive;
}
function sharedArchiveRoot(paths: string[]) {
if (paths.length === 0) return null;
const firstSegments = paths
.map((entry) => normalizeArchivePath(entry).split("/").filter(Boolean))
.filter((parts) => parts.length > 0);
if (firstSegments.length === 0) return null;
const candidate = firstSegments[0]![0]!;
return firstSegments.every((parts) => parts.length > 1 && parts[0] === candidate)
? candidate
: null;
}
export function readZipArchive(source: ArrayBuffer | Uint8Array): {
rootPath: string | null;
files: Record<string, string>;
} {
const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
const entries: Array<{ path: string; body: string }> = [];
let offset = 0;
while (offset + 4 <= bytes.length) {
const signature = readUint32(bytes, offset);
if (signature === 0x02014b50 || signature === 0x06054b50) break;
if (signature !== 0x04034b50) {
throw new Error("Invalid zip archive: unsupported local file header.");
}
if (offset + 30 > bytes.length) {
throw new Error("Invalid zip archive: truncated local file header.");
}
const generalPurposeFlag = readUint16(bytes, offset + 6);
const compressionMethod = readUint16(bytes, offset + 8);
const compressedSize = readUint32(bytes, offset + 18);
const fileNameLength = readUint16(bytes, offset + 26);
const extraFieldLength = readUint16(bytes, offset + 28);
if ((generalPurposeFlag & 0x0008) !== 0) {
throw new Error("Unsupported zip archive: data descriptors are not supported.");
}
if (compressionMethod !== 0) {
throw new Error("Unsupported zip archive: only uncompressed entries are supported.");
}
const nameOffset = offset + 30;
const bodyOffset = nameOffset + fileNameLength + extraFieldLength;
const bodyEnd = bodyOffset + compressedSize;
if (bodyEnd > bytes.length) {
throw new Error("Invalid zip archive: truncated file contents.");
}
const archivePath = normalizeArchivePath(
textDecoder.decode(bytes.slice(nameOffset, nameOffset + fileNameLength)),
);
if (archivePath && !archivePath.endsWith("/")) {
entries.push({
path: archivePath,
body: textDecoder.decode(bytes.slice(bodyOffset, bodyEnd)),
});
}
offset = bodyEnd;
}
const rootPath = sharedArchiveRoot(entries.map((entry) => entry.path));
const files: Record<string, string> = {};
for (const entry of entries) {
const normalizedPath =
rootPath && entry.path.startsWith(`${rootPath}/`)
? entry.path.slice(rootPath.length + 1)
: entry.path;
if (!normalizedPath) continue;
files[normalizedPath] = entry.body;
}
return { rootPath, files };
}
export function createZipArchive(files: Record<string, string>, rootPath: string): Uint8Array {
const normalizedRoot = normalizeArchivePath(rootPath);
const localChunks: Uint8Array[] = [];

View File

@@ -18,7 +18,6 @@ import {
Check,
Download,
Github,
Link2,
Package,
Upload,
} from "lucide-react";
@@ -33,6 +32,7 @@ import {
FRONTMATTER_FIELD_LABELS,
PackageFileTree,
} from "../components/PackageFileTree";
import { readZipArchive } from "../lib/zip";
// ── Import-specific helpers ───────────────────────────────────────────
@@ -253,12 +253,19 @@ function buildConflictList(
return conflicts;
}
/** Extract a prefix from the import source URL or local folder name */
function deriveSourcePrefix(sourceMode: string, importUrl: string, localRootPath: string | null): string | null {
if (sourceMode === "local" && localRootPath) {
return localRootPath.split("/").pop() ?? null;
/** Extract a prefix from the import source URL or uploaded zip package name */
function deriveSourcePrefix(
sourceMode: string,
importUrl: string,
localPackageName: string | null,
localRootPath: string | null,
): string | null {
if (sourceMode === "local") {
if (localRootPath) return localRootPath.split("/").pop() ?? null;
if (!localPackageName) return null;
return localPackageName.replace(/\.zip$/i, "") || null;
}
if (sourceMode === "github" || sourceMode === "url") {
if (sourceMode === "github") {
const url = importUrl.trim();
if (!url) return null;
try {
@@ -407,30 +414,23 @@ function ConflictResolutionList({
// ── Helpers ───────────────────────────────────────────────────────────
async function readLocalPackageSelection(fileList: FileList): Promise<{
async function readLocalPackageZip(file: File): Promise<{
name: string;
rootPath: string | null;
files: Record<string, string>;
}> {
const files: Record<string, string> = {};
let rootPath: string | null = null;
for (const file of Array.from(fileList)) {
const relativePath =
(file as File & { webkitRelativePath?: string }).webkitRelativePath?.replace(
/\\/g,
"/",
) || file.name;
const isMarkdown = relativePath.endsWith(".md");
const isPaperclipYaml =
relativePath.endsWith(".paperclip.yaml") || relativePath.endsWith(".paperclip.yml");
if (!isMarkdown && !isPaperclipYaml) continue;
const topLevel = relativePath.split("/")[0] ?? null;
if (!rootPath && topLevel) rootPath = topLevel;
files[relativePath] = await file.text();
if (!/\.zip$/i.test(file.name)) {
throw new Error("Select a .zip company package.");
}
if (Object.keys(files).length === 0) {
throw new Error("No package files were found in the selected folder.");
const archive = readZipArchive(await file.arrayBuffer());
if (Object.keys(archive.files).length === 0) {
throw new Error("No package files were found in the selected zip archive.");
}
return { rootPath, files };
return {
name: file.name,
rootPath: archive.rootPath,
files: archive.files,
};
}
// ── Main page ─────────────────────────────────────────────────────────
@@ -447,9 +447,10 @@ export function CompanyImport() {
const packageInputRef = useRef<HTMLInputElement | null>(null);
// Source state
const [sourceMode, setSourceMode] = useState<"github" | "url" | "local">("github");
const [sourceMode, setSourceMode] = useState<"github" | "local">("github");
const [importUrl, setImportUrl] = useState("");
const [localPackage, setLocalPackage] = useState<{
name: string;
rootPath: string | null;
files: Record<string, string>;
} | null>(null);
@@ -484,15 +485,9 @@ export function CompanyImport() {
}
const url = importUrl.trim();
if (!url) return null;
if (sourceMode === "github") return { type: "github", url };
return { type: "url", url };
return { type: "github", url };
}
const sourcePrefix = useMemo(
() => deriveSourcePrefix(sourceMode, importUrl, localPackage?.rootPath ?? null),
[sourceMode, importUrl, localPackage],
);
// Preview mutation
const previewMutation = useMutation({
mutationFn: () => {
@@ -513,7 +508,12 @@ export function CompanyImport() {
// Build conflicts and set default name overrides with prefix
const conflicts = buildConflictList(result);
const prefix = deriveSourcePrefix(sourceMode, importUrl, localPackage?.rootPath ?? null);
const prefix = deriveSourcePrefix(
sourceMode,
importUrl,
localPackage?.name ?? null,
localPackage?.rootPath ?? null,
);
const defaultOverrides: Record<string, string> = {};
for (const c of conflicts) {
@@ -625,7 +625,7 @@ export function CompanyImport() {
const fileList = e.target.files;
if (!fileList || fileList.length === 0) return;
try {
const pkg = await readLocalPackageSelection(fileList);
const pkg = await readLocalPackageZip(fileList[0]!);
setLocalPackage(pkg);
setImportPreview(null);
} catch (err) {
@@ -764,16 +764,15 @@ export function CompanyImport() {
<div>
<h2 className="text-base font-semibold">Import source</h2>
<p className="text-xs text-muted-foreground mt-1">
Choose a GitHub repo, direct URL, or local folder to import from.
Choose a GitHub repo or upload a local Paperclip zip package.
</p>
</div>
<div className="grid gap-2 md:grid-cols-3">
<div className="grid gap-2 md:grid-cols-2">
{(
[
{ key: "github", icon: Github, label: "GitHub repo" },
{ key: "url", icon: Link2, label: "Direct URL" },
{ key: "local", icon: Upload, label: "Local folder" },
{ key: "local", icon: Upload, label: "Local zip" },
] as const
).map(({ key, icon: Icon, label }) => (
<button
@@ -785,7 +784,10 @@ export function CompanyImport() {
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50",
)}
onClick={() => setSourceMode(key)}
onClick={() => {
setSourceMode(key);
setImportPreview(null);
}}
>
<div className="flex items-center gap-2">
<Icon className="h-4 w-4" />
@@ -800,10 +802,8 @@ export function CompanyImport() {
<input
ref={packageInputRef}
type="file"
multiple
accept=".zip,application/zip"
className="hidden"
// @ts-expect-error webkitdirectory is supported by Chromium-based browsers
webkitdirectory=""
onChange={handleChooseLocalPackage}
/>
<div className="flex flex-wrap items-center gap-2">
@@ -812,11 +812,11 @@ export function CompanyImport() {
variant="outline"
onClick={() => packageInputRef.current?.click()}
>
Choose folder
Choose zip
</Button>
{localPackage && (
<span className="text-xs text-muted-foreground">
{localPackage.rootPath ?? "package"} with{" "}
{localPackage.name} with{" "}
{Object.keys(localPackage.files).length} file
{Object.keys(localPackage.files).length === 1 ? "" : "s"}
</span>
@@ -824,28 +824,20 @@ export function CompanyImport() {
</div>
{!localPackage && (
<p className="mt-2 text-xs text-muted-foreground">
Select a folder that contains COMPANY.md and any referenced AGENTS.md files.
Upload a `.zip` exported from Paperclip that contains COMPANY.md and the related package files.
</p>
)}
</div>
) : (
<Field
label={sourceMode === "github" ? "GitHub URL" : "Package URL"}
hint={
sourceMode === "github"
? "Repo tree path or blob URL to COMPANY.md (e.g. github.com/owner/repo/tree/main/company)."
: "Point directly at COMPANY.md or a directory that contains it."
}
label="GitHub URL"
hint="Repo tree path or blob URL to COMPANY.md (e.g. github.com/owner/repo/tree/main/company)."
>
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={importUrl}
placeholder={
sourceMode === "github"
? "https://github.com/owner/repo/tree/main/company"
: "https://example.com/company/COMPANY.md"
}
placeholder="https://github.com/owner/repo/tree/main/company"
onChange={(e) => {
setImportUrl(e.target.value);
setImportPreview(null);
@@ -934,7 +926,7 @@ export function CompanyImport() {
/>
{/* Import button — below renames */}
<div className="mx-5 mt-3">
<div className="mx-5 mt-3 flex justify-end">
<Button
size="sm"
onClick={() => importMutation.mutate()}