From b41ff0377b59b8aec30cb7242743f6f307a8f3f0 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 17:53:30 -0500 Subject: [PATCH] fix: attest single-file ACP bootstrap read targets Co-Authored-By: Paperclip --- .../src/drivers/acpx/copilot-tool-evidence.ts | 6 +- .../src/drivers/acpx/cursor-tool-evidence.ts | 6 +- .../drivers/acpx/single-read-evidence.test.ts | 56 +++++++++++++++++++ .../src/drivers/acpx/single-read-evidence.ts | 47 ++++++++++++++++ 4 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 packages/paperclip-runner/src/drivers/acpx/single-read-evidence.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/single-read-evidence.ts diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts index e82d356142..3f186a7327 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts @@ -1,3 +1,4 @@ +import { updateSingleReadEvidence, type SingleReadEvidence } from "./single-read-evidence.js"; import { createHash } from "node:crypto"; import { redactPaperclipSemanticValue } from "../../semantic-tools/redaction.js"; import type { CanonicalProviderEvent } from "../../provider-events.js"; @@ -6,7 +7,7 @@ import { safeAcpxLocations } from "./safe-locations.js"; const LIMIT = 256; const CATEGORY = "copilot_tool_evidence_v1"; type Fields = Record; -interface Tool { kind?: string; input?: string; fields: Fields; invalid?: boolean } +interface Tool { kind?: string; input?: string; fields: Fields; invalid?: boolean; read?: SingleReadEvidence } const record = (v: unknown): Record => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record : {}; const identity = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v); const shellIdentity = (v: unknown): v is string => typeof v === "string" && /^[A-Za-z0-9_.-]{1,80}$/u.test(v); @@ -106,6 +107,7 @@ export function createCopilotToolEvidence(binding: { if (state.kind && state.kind !== call.kind) { state.invalid = true; notice("evidence_incomplete", id, { reason: "changed_tool_kind" }); return; } state.kind = call.kind; } + if (state.kind === "read") state.read = updateSingleReadEvidence(state.read, call, binding.workingDirectory); if (call.rawInput !== undefined) { const fields = inputFields({ ...call, kind: state.kind }); const fingerprint = JSON.stringify(fields); @@ -114,7 +116,7 @@ export function createCopilotToolEvidence(binding: { } const status = ["pending", "in_progress", "completed", "failed"].includes(String(call.status)) ? String(call.status) : undefined; if (!status) return; - const fields: Fields = { ...state.fields, status }; + const fields: Fields = { ...state.fields, status, ...(state.read?.targetSha256 ? { readTargetSha256: state.read.targetSha256 } : {}) }; if (state.kind === "edit" || state.kind === "read") fields.operation = state.kind; const output = record(call.rawOutput).content; // Parse only the pinned native wrapper, never substrings in command output. diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.ts index fe8d0f5c7b..b7f9979317 100644 --- a/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.ts +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.ts @@ -1,3 +1,4 @@ +import { updateSingleReadEvidence, type SingleReadEvidence } from "./single-read-evidence.js"; import { createHash } from "node:crypto"; import { redactPaperclipSemanticValue } from "../../semantic-tools/redaction.js"; import type { CanonicalProviderEvent } from "../../provider-events.js"; @@ -6,7 +7,7 @@ const rec = (v: unknown): Record => v !== null && typeof v === const id = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v); const digest = (s: string) => createHash("sha256").update(s).digest("hex"); type Fields = Record; -type Tool = { kind?: string; commandSha256?: string }; +type Tool = { kind?: string; commandSha256?: string; read?: SingleReadEvidence }; type Permission = { requestId: string; kind?: string; hasInput: boolean; commandSha256?: string; declineOffered: boolean; requested?: boolean; outcome?: string; delivered?: boolean }; /** Passive, bounded evidence from the active prompt iterator only. Cursor's @@ -72,6 +73,7 @@ export function createCursorToolEvidence(binding: { if (state.kind && state.kind !== call.kind) throw new Error("Changed tool kind"); state.kind = call.kind; } + if (state.kind === "read") state.read = updateSingleReadEvidence(state.read, call, binding.workingDirectory); if (call.rawInput !== undefined && state.kind === "execute") { const hash = command(call); if (!hash || (call.tag !== "tool_call" && !state.commandSha256) || (state.commandSha256 && state.commandSha256 !== hash)) throw new Error("Changed or missing command"); @@ -80,7 +82,7 @@ export function createCursorToolEvidence(binding: { if (!["pending", "in_progress", "completed", "failed"].includes(String(call.status))) return; // Publish queued permission delivery before its terminal tool result. if (call.tag !== "tool_call") flush(toolId); - notice("tool", toolId, { status: String(call.status), ...(["execute", "read"].includes(state.kind ?? "") ? { operation: state.kind! } : {}), ...(state.commandSha256 ? { commandSha256: state.commandSha256 } : {}) }); + notice("tool", toolId, { status: String(call.status), ...(["execute", "read"].includes(state.kind ?? "") ? { operation: state.kind! } : {}), ...(state.commandSha256 ? { commandSha256: state.commandSha256 } : {}), ...(state.read?.targetSha256 ? { readTargetSha256: state.read.targetSha256 } : {}) }); flush(toolId); }); }, permission(request: unknown, requestId: string, offeredActions: readonly string[]) { diff --git a/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.test.ts b/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.test.ts new file mode 100644 index 0000000000..b64de14c8a --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.test.ts @@ -0,0 +1,56 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { updateSingleReadEvidence } from "./single-read-evidence.js"; +import { createCursorToolEvidence } from "./cursor-tool-evidence.js"; +import { createCopilotToolEvidence } from "./copilot-tool-evidence.js"; +import { validateAcpxRichEvent } from "./profile-extensions.js"; +const path = `.paperclip-eval-action-${"a".repeat(36)}.txt`; +const hash = `sha256:${createHash("sha256").update(path).digest("hex")}`; +const origin = { type: "tool_call", tag: "tool_call", toolCallId: "read", kind: "read", status: "pending", rawInput: { path }, locations: [{ path }] }; +it("attests one explicit input path and retains proof through omitted native update fields", () => { + for (const rawInput of [{ path }, { fileName: `/workspace/${path}` }, { path, offset: 0, limit: 10 }]) { + const state = updateSingleReadEvidence(undefined, { ...origin, rawInput }, "/workspace"); expect(state.targetSha256).toBe(hash); + expect(updateSingleReadEvidence(state, { tag: "tool_call_update", status: "completed" }, "/workspace")).toEqual(state); + } +}); +it.each([ + { paths: [path] }, { path, paths: [path, "private.txt"] }, { path, fileName: path }, { path: [path] }, { path, extra: { path: "private.txt" } }, + { path, unknown: "private.txt" }, { path: "../private.txt" }, { path: "https://example.com/file" }, { shellId: "0" }, +])("never attests ambiguous or unsafe input %j", rawInput => { + expect(updateSingleReadEvidence(undefined, { ...origin, rawInput }, "/workspace").targetSha256).toBeUndefined(); + const state = updateSingleReadEvidence(undefined, origin, "/workspace"); + expect(() => updateSingleReadEvidence(state, { ...origin, tag: "tool_call_update", rawInput }, "/workspace")).toThrow(); +}); +it.each([[{ path }, { path }], [{ path }, { path: "../outside" }], [{ path: "other" }], null])("rejects ambiguous/conflicting locations %j", locations => { + expect(updateSingleReadEvidence(undefined, { ...origin, locations }, "/workspace").targetSha256).toBeUndefined(); + const state = updateSingleReadEvidence(undefined, origin, "/workspace"); + expect(() => updateSingleReadEvidence(state, { tag: "tool_call_update", locations }, "/workspace")).toThrow(); +}); +it("does not establish proof from a late update or location/title without scalar input", () => { + expect(updateSingleReadEvidence(undefined, { ...origin, tag: "tool_call_update" }, "/workspace")).toEqual({}); + const state = updateSingleReadEvidence(undefined, { ...origin, rawInput: undefined, title: `Read ${path}` }, "/workspace"); + expect(state).toEqual({}); expect(updateSingleReadEvidence(state, origin, "/workspace")).toEqual({}); +}); +for (const [provider, create] of [["cursor", createCursorToolEvidence], ["copilot", createCopilotToolEvidence]] as const) { + describe(`${provider} passive single-read origin`, () => { + function setup() { const rows: any[] = []; const projector = create({ sessionId: "session", turnId: "turn", workingDirectory: "/workspace", active: () => true, + emit: event => { validateAcpxRichEvent(event); rows.push(Object.fromEntries((event.payload.details as any[]).map(d => [d.name, d.value]))); } }); return { rows, projector }; } + it.each(["completed", "failed"])("retains exact proof in %s terminal and does not emit raw path", status => { + const s = setup(); s.projector.tool(origin); s.projector.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "read", status }); + expect(s.rows.map(row => row.readTargetSha256)).toEqual([hash, hash]); expect(JSON.stringify(s.rows)).not.toContain(path); + const firstTerminal = setup(); firstTerminal.projector.tool({ ...origin, status }); expect(firstTerminal.rows[0].readTargetSha256).toBe(hash); + }); + it("marks changed same-path ambiguous input or locations incomplete without throwing through execution", () => { + for (const fields of [{ rawInput: { path, paths: [path, "private.txt"] } }, { locations: [{ path }, { path: "private.txt" }] }]) { + const s = setup(); s.projector.tool(origin); + expect(() => s.projector.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "read", status: "completed", ...fields })).not.toThrow(); + expect(s.rows.at(-1).stage).toBe("evidence_incomplete"); + } + }); + it("does not attest multi-file reads or inherit another tool's origin", () => { + const s = setup(); s.projector.tool({ ...origin, rawInput: { paths: [path, "private.txt"] } }); + s.projector.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "other", kind: "read", status: "completed", rawInput: { path } }); + expect(s.rows).toHaveLength(1); expect(s.rows[0].readTargetSha256).toBeUndefined(); + }); + }); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.ts b/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.ts new file mode 100644 index 0000000000..cbea42d835 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.ts @@ -0,0 +1,47 @@ +import { createHash } from "node:crypto"; +import { safeAcpxLocations } from "./safe-locations.js"; + +export interface SingleReadEvidence { targetSha256?: string } +const record = (value: unknown): Record | undefined => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; +const digest = (value: string) => `sha256:${createHash("sha256").update(value).digest("hex")}`; +const scalarOptions = new Set(["offset", "limit", "startLine", "endLine"]); +function safePath(value: unknown, cwd: string): string | undefined { + if (typeof value !== "string" || !value || Buffer.byteLength(value) > 4096 || /[\u0000-\u001f\u007f\\]/u.test(value)) return; + const locations = safeAcpxLocations([{ path: value }], cwd, "read"); + const path = locations[0]?.path; + return locations.length === 1 && typeof path === "string" && !path.includes(":") ? path : undefined; +} +function locationsAgree(value: unknown, cwd: string, expected: string): boolean { + return value === undefined || (Array.isArray(value) && value.length <= 1 && value.every(entry => { + const location = record(entry); return location !== undefined && safePath(location.path, cwd) === expected; + })); +} +function singleInputPath(value: unknown, cwd: string): string | undefined { + const input = record(value); if (!input) return; + const keys = Object.keys(input), paths = keys.filter(key => key === "path" || key === "fileName"); + if (paths.length !== 1 || keys.length > 5 || keys.some(key => key !== "path" && key !== "fileName" + && (!scalarOptions.has(key) || typeof input[key] !== "number" || !Number.isSafeInteger(input[key]) || (input[key] as number) < 0))) return; + return safePath(input[paths[0]!], cwd); +} + +/** Passive attestation of the pinned single-file read input, never permission or + * filesystem authority. Multi-path tools and shell-output reads remain visible + * but cannot acquire this proof. Only the actual origin may establish a digest; + * absent update fields retain it, while changed or ambiguous fields invalidate + * evidence without changing the provider's response or execution. */ +export function updateSingleReadEvidence(state: SingleReadEvidence | undefined, call: Record, cwd: string): SingleReadEvidence { + if (!state) { + const path = call.tag === "tool_call" ? singleInputPath(call.rawInput, cwd) : undefined; + return path && locationsAgree(call.locations, cwd, path) ? { targetSha256: digest(path) } : {}; + } + if (!state.targetSha256) return state; // A later delta cannot create an origin. + if (call.rawInput !== undefined) { + const path = singleInputPath(call.rawInput, cwd); + if (!path || digest(path) !== state.targetSha256 || !locationsAgree(call.locations, cwd, path)) throw new Error("Changed single-read evidence input"); + } else if (call.locations !== undefined) { + if (!Array.isArray(call.locations) || call.locations.length > 1 || call.locations.some(location => { + const path = safePath(record(location)?.path, cwd); return !path || digest(path) !== state.targetSha256; + })) throw new Error("Changed single-read evidence locations"); + } + return state; +}