mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 13:38:01 +02:00
fix: attest single-file ACP bootstrap read targets
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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;
|
||||
}
|
||||
Reference in new issue
Block a user