diff --git a/packages/paperclip-runner/src/live/workspace-file-reference.test.ts b/packages/paperclip-runner/src/live/workspace-file-reference.test.ts new file mode 100644 index 0000000000..8cc3456b01 --- /dev/null +++ b/packages/paperclip-runner/src/live/workspace-file-reference.test.ts @@ -0,0 +1,148 @@ +import { link, mkdir, mkdtemp, realpath, rm, symlink, unlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + discoverPaperclipWorkspaceFileReferences, + paperclipWorkspaceFileReferencesFromText, +} from "./workspace-file-reference.js"; + +describe("workspace file references", () => { + it("normalizes Markdown links without retaining unproven file bytes", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-file-reference-")); + try { + await writeFile(join(root, "guide.md"), "# Guide\n\nSafe content.\n"); + const canonicalRoot = await realpath(root); + const references = await discoverPaperclipWorkspaceFileReferences( + root, + `Read [the guide](${join(canonicalRoot, "guide.md")}:2).`, + "turn-1", + ); + expect(references).toHaveLength(1); + expect(references[0]).toMatchObject({ + schema: "paperclip.workspace.file_reference.v1", + source: "runner_verified", + path: "guide.md", + displayName: "the guide", + presentation: "document", + line: 2, + preview: null, + contentDigest: null, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("rejects external URLs and paths outside the authorized workspace", () => { + const references = paperclipWorkspaceFileReferencesFromText( + "/workspace", + "[external](https://example.com/file.md) [outside](/etc/passwd) [safe](docs/safe.md)", + "turn-1", + ); + expect(references.map((reference) => reference.path)).toEqual(["docs/safe.md"]); + }); + + it("does not verify a symlink that resolves outside the workspace", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-file-reference-root-")); + const outside = await mkdtemp(join(tmpdir(), "paperclip-file-reference-outside-")); + try { + const protectedPath = join(outside, "protected.md"); + await writeFile(protectedPath, "must not be disclosed"); + await symlink(protectedPath, join(root, "linked.md")); + + await expect(discoverPaperclipWorkspaceFileReferences( + root, + "[linked](linked.md)", + "turn-1", + )).resolves.toEqual([]); + } finally { + await Promise.all([ + rm(root, { recursive: true, force: true }), + rm(outside, { recursive: true, force: true }), + ]); + } + }); + + it("anchors parsing and verification to a canonical workspace root", async () => { + const parent = await mkdtemp(join(tmpdir(), "paperclip-file-reference-parent-")); + const root = join(parent, "workspace"); + const alias = join(parent, "workspace-alias"); + try { + await mkdir(root); + await writeFile(join(root, "guide.md"), "# Guide\n"); + await symlink(root, alias); + const canonicalRoot = await realpath(root); + + await expect(discoverPaperclipWorkspaceFileReferences( + alias, + `[guide](${join(canonicalRoot, "guide.md")})`, + "turn-1", + )).resolves.toEqual([ + expect.objectContaining({ + path: "guide.md", + source: "runner_verified", + }), + ]); + } finally { + await rm(parent, { recursive: true, force: true }); + } + }); + + it("does not expose bytes through an in-workspace hard link", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-file-reference-root-")); + const outside = await mkdtemp(join(tmpdir(), "paperclip-file-reference-outside-")); + try { + const protectedPath = join(outside, "protected.md"); + await writeFile(protectedPath, "must not be disclosed"); + await link(protectedPath, join(root, "linked.md")); + + await expect(discoverPaperclipWorkspaceFileReferences( + root, + "[linked](linked.md)", + "turn-1", + )).resolves.toEqual([ + expect.objectContaining({ + path: "linked.md", + preview: null, + contentDigest: null, + }), + ]); + } finally { + await Promise.all([ + rm(root, { recursive: true, force: true }), + rm(outside, { recursive: true, force: true }), + ]); + } + }); + + it("does not expose bytes after an outside hard link is removed", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-file-reference-root-")); + const outside = await mkdtemp(join(tmpdir(), "paperclip-file-reference-outside-")); + try { + const protectedPath = join(outside, "protected.md"); + await writeFile(protectedPath, "must not be disclosed"); + await link(protectedPath, join(root, "linked.md")); + await unlink(protectedPath); + + await expect(discoverPaperclipWorkspaceFileReferences( + root, + "[linked](linked.md)", + "turn-1", + )).resolves.toEqual([ + expect.objectContaining({ + path: "linked.md", + preview: null, + contentDigest: null, + }), + ]); + } finally { + await Promise.all([ + rm(root, { recursive: true, force: true }), + rm(outside, { recursive: true, force: true }), + ]); + } + }); +}); diff --git a/packages/paperclip-runner/src/live/workspace-file-reference.ts b/packages/paperclip-runner/src/live/workspace-file-reference.ts new file mode 100644 index 0000000000..043cf00742 --- /dev/null +++ b/packages/paperclip-runner/src/live/workspace-file-reference.ts @@ -0,0 +1,141 @@ +import { createHash } from "node:crypto"; +import { realpath } from "node:fs/promises"; +import { basename, extname, isAbsolute, relative, resolve, sep } from "node:path"; + +export const PAPERCLIP_WORKSPACE_FILE_REFERENCE_SCHEMA = "paperclip.workspace.file_reference.v1" as const; + +export interface PaperclipWorkspaceFileReference { + schema: typeof PAPERCLIP_WORKSPACE_FILE_REFERENCE_SCHEMA; + referenceId: string; + source: "harness_reported" | "runner_verified"; + path: string; + displayName: string; + mediaType: string | null; + presentation: "document" | "code" | "image" | "generic"; + line: number | null; + preview: string | null; + previewTruncated: boolean; + contentDigest: string | null; +} + +const MAX_REFERENCES = 32; +const MAX_LINE_NUMBER = 1_000_000; + +function media(path: string): Pick { + const extension = extname(path).toLowerCase(); + if ([".md", ".mdx", ".txt", ".rst"].includes(extension)) { + return { mediaType: extension === ".md" || extension === ".mdx" ? "text/markdown" : "text/plain", presentation: "document" }; + } + if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"].includes(extension)) { + const imageType = extension === ".jpg" ? "jpeg" : extension.slice(1); + return { mediaType: `image/${imageType}`, presentation: "image" }; + } + if ([".ts", ".tsx", ".js", ".jsx", ".json", ".rs", ".py", ".go", ".java", ".css", ".html", ".yaml", ".yml", ".toml", ".sh"].includes(extension)) { + return { mediaType: "text/plain", presentation: "code" }; + } + return { mediaType: null, presentation: "generic" }; +} + +function safeLineNumber(value: string): number | null { + const line = Number(value); + return Number.isSafeInteger(line) && line > 0 && line <= MAX_LINE_NUMBER + ? line + : null; +} + +function localTarget(rawTarget: string): { target: string; line: number | null } | null { + let target = rawTarget.trim(); + if (target.startsWith("<") && target.endsWith(">")) target = target.slice(1, -1); + if (/^[a-z][a-z0-9+.-]*:/i.test(target) && !target.startsWith("file:")) return null; + if (target.startsWith("file://")) { + try { target = decodeURIComponent(new URL(target).pathname); } catch { return null; } + } else { + try { target = decodeURIComponent(target); } catch { return null; } + } + let line: number | null = null; + const hashLine = target.match(/#L(\d+)(?:C\d+)?$/i); + if (hashLine) { + line = safeLineNumber(hashLine[1]); + target = target.slice(0, hashLine.index); + } else { + const suffixLine = target.match(/:(\d+)$/); + if (suffixLine) { + line = safeLineNumber(suffixLine[1]); + target = target.slice(0, suffixLine.index); + } + } + return target.length === 0 ? null : { target, line }; +} + +function workspaceRelativePath(root: string, target: string): string | null { + const candidate = relative(root, target); + if ( + !candidate || + candidate === ".." || + candidate.startsWith(`..${sep}`) || + isAbsolute(candidate) + ) return null; + return candidate.split(sep).join("/"); +} + +export function paperclipWorkspaceFileReferencesFromText( + workspace: string, + assistantText: string, + turnId: string, + source: PaperclipWorkspaceFileReference["source"] = "harness_reported", +): PaperclipWorkspaceFileReference[] { + const root = resolve(workspace); + const references: PaperclipWorkspaceFileReference[] = []; + const seen = new Set(); + const links = assistantText.matchAll(/\[([^\]]+)]\((<[^>]+>|[^)\s]+)(?:\s+["'][^"']*["'])?\)/g); + for (const match of links) { + if (references.length >= MAX_REFERENCES) break; + const parsed = localTarget(match[2] ?? ""); + if (parsed === null) continue; + const relativePath = workspaceRelativePath(root, resolve(root, parsed.target)); + if (relativePath === null) continue; + const key = `${relativePath}:${parsed.line ?? ""}`; + if (seen.has(key)) continue; + seen.add(key); + const identity = createHash("sha256").update(`${turnId}\0${key}`).digest("hex").slice(0, 24); + references.push({ + schema: PAPERCLIP_WORKSPACE_FILE_REFERENCE_SCHEMA, + referenceId: `${turnId}:file:${identity}`, + source, + path: relativePath, + displayName: (match[1]?.trim() || basename(relativePath)).slice(0, 255), + ...media(relativePath), + line: parsed.line, + preview: null, + previewTruncated: false, + contentDigest: null, + }); + } + return references; +} + +export async function discoverPaperclipWorkspaceFileReferences( + workspace: string, + assistantText: string, + turnId: string, +): Promise { + let canonicalRoot: string; + try { canonicalRoot = await realpath(workspace); } catch { return []; } + // Resolve references from the same canonical root used for the containment + // check. Do not consult a caller-controlled workspace symlink again after + // canonicalizing it above. + const references = paperclipWorkspaceFileReferencesFromText(canonicalRoot, assistantText, turnId, "runner_verified"); + const verified: PaperclipWorkspaceFileReference[] = []; + for (const reference of references) { + let canonicalPath: string; + try { canonicalPath = await realpath(resolve(canonicalRoot, reference.path)); } catch { continue; } + if (workspaceRelativePath(canonicalRoot, canonicalPath) === null) continue; + // POSIX exposes an inode's current links, not where its bytes originated. + // Once an outside hard link is removed, an in-workspace link is + // indistinguishable from a file created inside the workspace. Keep the + // canonical, bounded reference metadata but do not retain bytes until a + // trusted workspace snapshot/manifest can prove file provenance. + verified.push(reference); + } + return verified; +}