fix: attest single-file ACP bootstrap read targets

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-29 17:54:12 -05:00
1 parent 1597325184
commit b41ff0377b
4 files changed
+111 -4

No files matched your search

@@ -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<string, string | number | boolean>;
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<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record<string, unknown> : {};
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.
@@ -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<string, unknown> => 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<string, string | boolean>;
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[]) {
@@ -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();
});
});
}
@@ -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<string, unknown> | undefined => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : 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<string, unknown>, 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;
}