diff --git a/tests/runner-e2e/FIXTURES.md b/tests/runner-e2e/FIXTURES.md index a9c90390af3..d252f1fc13f 100644 --- a/tests/runner-e2e/FIXTURES.md +++ b/tests/runner-e2e/FIXTURES.md @@ -336,3 +336,14 @@ retirement checks. Runtime-internal files are an explicit scoped exclusion. `human-permission-denial` requires native request/tool correlation, the browser's exact Decline, delivered denial and failed write, and unchanged file evidence through retirement. All normal project and company boundaries apply. + +Cursor and Copilot may exempt a bootstrap read only when every native notice for +that completed tool origin carries the single-path attestation matching the +observer's exact random action file. The passive projector derives this digest +from one explicit scalar native input path, rejects ambiguous/multiple paths, +and checks subsequent input/location updates for changes. Durable canonical +execution receipts must match the same run, turn, execution identity, status, +and order; any explicit canonical target must agree. Missing or conflicting +attestations fail qualification. PRP retains only the first location and terminal +updates may omit it, so canonical targets alone cannot prove bootstrap ownership. +Neither tool titles nor output text supplies path evidence. diff --git a/tests/runner-e2e/copilot-evidence.ts b/tests/runner-e2e/copilot-evidence.ts index 099a7620a8a..6a74c589d53 100644 --- a/tests/runner-e2e/copilot-evidence.ts +++ b/tests/runner-e2e/copilot-evidence.ts @@ -6,13 +6,13 @@ export interface CopilotToolNotice { status?: "pending" | "in_progress" | "completed" | "failed"; operation?: "edit" | "execute" | "read"; target?: string; requestId?: string; declineOffered?: boolean; outcome?: "allow_once" | "allow_always" | "reject_once" | "cancel"; - commandSha256?: string; mode?: "sync" | "async"; detach?: boolean; + commandSha256?: string; readTargetSha256?: string; mode?: "sync" | "async"; detach?: boolean; shellId?: string; commandToolCallId?: string; shellState?: "started" | "completed"; exitCode?: number; } const rec = (v: unknown): Record => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record : {}; const id = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v) && !v.includes("[REDACTED]"); const enums = { stage: ["tool", "permission_requested", "permission_delivered"], status: ["pending", "in_progress", "completed", "failed"], operation: ["edit", "execute", "read"], outcome: ["allow_once", "allow_always", "reject_once", "cancel"], mode: ["sync", "async"], shellState: ["started", "completed"] }; -const names = new Set(["stage", "toolCallId", "status", "operation", "target", "requestId", "declineOffered", "outcome", "commandSha256", "mode", "detach", "shellId", "commandToolCallId", "shellState", "exitCode"]); +const names = new Set(["stage", "toolCallId", "status", "operation", "target", "requestId", "declineOffered", "outcome", "commandSha256", "readTargetSha256", "mode", "detach", "shellId", "commandToolCallId", "shellState", "exitCode"]); export function readCopilotToolEvidence(rows: readonly unknown[], expectedRunId: string): CopilotToolNotice[] { const result: CopilotToolNotice[] = []; for (const row of rows) { @@ -38,6 +38,7 @@ export function readCopilotToolEvidence(rows: readonly unknown[], expectedRunId: if (!fields.target || fields.target.startsWith("/") || /[\\:\u0000-\u001f\u007f]/u.test(fields.target) || fields.target.split("/").some(x => x === "." || x === ".." || !x)) throw new Error("Invalid Copilot evidence target"); notice.target = fields.target; } + if (fields.readTargetSha256 !== undefined) { if (!/^sha256:[a-f0-9]{64}$/u.test(fields.readTargetSha256)) throw new Error("Invalid read digest"); notice.readTargetSha256 = fields.readTargetSha256; } if (fields.commandSha256 !== undefined) { if (!/^sha256:[a-f0-9]{64}$/u.test(fields.commandSha256)) throw new Error("Invalid command digest"); notice.commandSha256 = fields.commandSha256; } if (fields.shellId !== undefined) { if (!/^[A-Za-z0-9_.-]{1,80}$/u.test(fields.shellId)) throw new Error("Invalid shell identity"); notice.shellId = fields.shellId; } if (fields.exitCode !== undefined) { if (!/^-?\d{1,10}$/u.test(fields.exitCode) || !Number.isSafeInteger(Number(fields.exitCode))) throw new Error("Invalid exit code"); notice.exitCode = Number(fields.exitCode); } diff --git a/tests/runner-e2e/copilot-protection-evidence.test.ts b/tests/runner-e2e/copilot-protection-evidence.test.ts index f314c5659d4..e58e54e261f 100644 --- a/tests/runner-e2e/copilot-protection-evidence.test.ts +++ b/tests/runner-e2e/copilot-protection-evidence.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -69,14 +70,25 @@ describe("Copilot sealed remote proof", () => { ]) { const s = receipt(); mutate(s); expect(() => assertCopilotRemoteAttached(s, baseline(), 50)).toThrow(); } expect(() => assertCopilotRemoteAttached(receipt(), baseline(), 20)).toThrow(); }); - it("allows only explicit same-turn bootstrap reads before the tested native origin", () => { + it("exempts only exact completed action-file reads before the tested operation", () => { const call = { ...notice, seq: 10, status: "in_progress" as const }; - const read = { ...notice, toolCallId: "bootstrap", operation: "read", seq: 1 } as unknown as CopilotToolNotice; - expect(countCopilotToolOrigins(copilotActionNotices([read, call], call, true))).toBe(1); - for (const bad of [{ ...read, seq: 11 }, { ...read, turnId: "other" }, { ...read, operation: undefined }, { ...read, operation: "edit" as const }]) { - expect(countCopilotToolOrigins(copilotActionNotices([bad, call], call, true))).toBe(2); + const read = { ...notice, toolCallId: "bootstrap", operation: "read", status: "completed", seq: 1 } as CopilotToolNotice; + const actionFile = `.paperclip-eval-action-${"a".repeat(36)}.txt`; + const event = { runId: "run", seq: 2, protocolSchemaVersion: 1, eventType: "tool.execution.completed", payload: { prpEvent: { + schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", runId: "run", turnId: "turn", eventType: "tool.execution.completed", emittedAt: new Date(2).toISOString(), + payload: { schema: "paperclip.tool.execution.v1", executionId: "bootstrap", transport: "builtin", operation: "read", target: actionFile, status: "completed" }, + } } }; + read.readTargetSha256 = `sha256:${createHash("sha256").update(actionFile).digest("hex")}`; + const proof = { actionFile, events: [event] }; + expect(countCopilotToolOrigins(copilotActionNotices([read, call], call, proof))).toBe(1); + const wrong = structuredClone(event); wrong.payload.prpEvent.payload.target = "unrelated.txt"; + expect(() => copilotActionNotices([read, call], call, { actionFile, events: [wrong] })).toThrow(); + expect(() => copilotActionNotices([read, { ...read, toolCallId: "unrelated", seq: 3 }, call], call, proof)).toThrow(); + expect(() => copilotActionNotices([{ ...read, turnId: "other" }, call], call, proof)).toThrow(); + for (const bad of [{ ...read, seq: 11 }, { ...read, operation: undefined }, { ...read, operation: "edit" as const }]) { + expect(countCopilotToolOrigins(copilotActionNotices([bad, call], call, { actionFile, events: [] }))).toBe(2); } - expect(countCopilotToolOrigins(copilotActionNotices([read, call], call, false))).toBe(2); + expect(countCopilotToolOrigins(copilotActionNotices([read, call], call))).toBe(2); }); it("awaits the remote fixture and baseline before disclosing the actual command", async () => { const s = baseline(), order: string[] = []; diff --git a/tests/runner-e2e/copilot-protection-evidence.ts b/tests/runner-e2e/copilot-protection-evidence.ts index 3f19690dab2..61ce447f7e8 100644 --- a/tests/runner-e2e/copilot-protection-evidence.ts +++ b/tests/runner-e2e/copilot-protection-evidence.ts @@ -1,3 +1,4 @@ +import { withoutProvenBootstrapReads, type BootstrapReadProof } from "./native-bootstrap-read-proof.js"; import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import type { CopilotToolNotice } from "./copilot-evidence.js"; @@ -66,9 +67,8 @@ export function copilotRemoteDeniedSample(s: CopilotRemoteSnapshot, baseline: Co return { phase, observedAtMs: s.observedAtMs, exists: t.absent !== true || t.sha256 !== null }; } /** Only typed reads before the tested operation can be bootstrap work. */ -export function copilotActionNotices(notices: readonly CopilotToolNotice[], origin: CopilotToolNotice, remote: boolean): CopilotToolNotice[] { - return notices.filter(n => !(remote && (n.operation as string) === "read" && n.seq < origin.seq - && n.runId === origin.runId && n.sessionId === origin.sessionId && n.turnId === origin.turnId)); +export function copilotActionNotices(notices: readonly CopilotToolNotice[], origin: CopilotToolNotice, proof?: BootstrapReadProof): CopilotToolNotice[] { + return withoutProvenBootstrapReads(notices, origin, proof); } export function assertCopilotRemoteAttached(s: CopilotRemoteSnapshot, baseline: CopilotRemoteSnapshot, terminalAt: number) { assertCopilotRemoteRetirement(s, baseline); diff --git a/tests/runner-e2e/copilot-protection-flow.ts b/tests/runner-e2e/copilot-protection-flow.ts index 3238f379db7..83129531ca6 100644 --- a/tests/runner-e2e/copilot-protection-flow.ts +++ b/tests/runner-e2e/copilot-protection-flow.ts @@ -152,12 +152,12 @@ export async function runCopilotProtectionFlow(input: { await input.evidence("copilot-denial-proof.json", { evidence, processes, notices }); const grade = gradeCopilotDeniedWrite(evidence); check("denial-without-side-effects", grade.passed, grade.failures.join(", ") || "Exact browser denial, explicit cancellation and absence through process cleanup"); check("negative-task-unfinished", issue.status === "in_progress", "The negative test does not claim the task is done"); - check("no-extra-native-operation", countCopilotToolOrigins(copilotActionNotices(notices, notices.find(n => n.stage === "tool" && n.toolCallId === request.toolCallId)!, remote)) === 1, "No alternate native edit, command or delegated operation is permitted"); + check("no-extra-native-operation", countCopilotToolOrigins(copilotActionNotices(notices, notices.find(n => n.stage === "tool" && n.toolCallId === request.toolCallId)!, remoteFixture ? { actionFile: remoteFixture.actionFile, events: runEvents } : undefined)) === 1, "No alternate native edit, command or delegated operation is permitted"); } else { await wait("attached command and task settlement", s => s.issue.status === "done" && s.runs[0]?.status === "succeeded" && (remote || (s.processes.captured && s.processes.live.length === 0))); const call = notices.find(n => n.stage === "tool" && n.status === "pending" && n.commandSha256 === exactCommand()!.commandSha256); check("single-exact-command", Boolean(call) && countCopilotToolOrigins(notices.filter(n => n.commandSha256 === exactCommand()!.commandSha256)) === 1, "Exactly one native execution contains the supplied command digest"); - if (remote) check("no-extra-native-operation", copilotActionNotices(notices, call!, true).every(n => + if (remote) check("no-extra-native-operation", copilotActionNotices(notices, call!, { actionFile: remoteFixture!.actionFile, events: runEvents }).every(n => n.runId === call!.runId && n.sessionId === call!.sessionId && n.turnId === call!.turnId && (n.toolCallId === call!.toolCallId || n.commandToolCallId === call!.toolCallId)), "Only setup reads and the exact native attached command/result are allowed"); const started = notices.find(n => n.toolCallId === call!.toolCallId && n.shellState === "started"); diff --git a/tests/runner-e2e/cursor-native-cases.ts b/tests/runner-e2e/cursor-native-cases.ts index bd646bb76ed..943bcf49a23 100644 --- a/tests/runner-e2e/cursor-native-cases.ts +++ b/tests/runner-e2e/cursor-native-cases.ts @@ -1,3 +1,4 @@ +import type { BootstrapReadProof } from "./native-bootstrap-read-proof.js"; import { cursorDeniedCommand, hasCursorDeniedCommand, type CursorToolNotice } from "./cursor-native-evidence.js"; import type { RunnerTaskFixture } from "./types.js"; @@ -81,14 +82,14 @@ export const CURSOR_DENIAL_SAMPLE_PHASES = ["before-request", "pending", "browse /** Samples are taken independently by the fixture, never supplied by the model. */ export function hasCursorDenialBoundary(input: { request: unknown; expectedRequestId: string; expectedToolCallId: string; - path: string; allowBootstrapReads?: boolean; notices: readonly CursorToolNotice[]; runId: string; turnId: string; samples: readonly { phase: string; path: string; absent: boolean; observedAt: number }[]; + path: string; bootstrapReadProof?: BootstrapReadProof; notices: readonly CursorToolNotice[]; runId: string; turnId: string; samples: readonly { phase: string; path: string; absent: boolean; observedAt: number }[]; }): boolean { const request = record(input.request); const origin = record(request.origin); const details = record(request.details); if (!input.expectedRequestId.trim() || !input.expectedToolCallId.trim() || !input.path.trim() || request.requestId !== input.expectedRequestId || details.toolCallId !== input.expectedToolCallId || request.type !== "permission" || request.status !== "pending" || origin.provider !== "cursor" || origin.adapter !== "acpx-runtime" || origin.method !== "session/request_permission") return false; - if (request.turnId !== input.turnId || !hasCursorDeniedCommand({ notices: input.notices, runId: input.runId, turnId: input.turnId, requestId: input.expectedRequestId, toolCallId: input.expectedToolCallId, commandSha256: cursorDeniedCommand(input.path).commandSha256, allowBootstrapReads: input.allowBootstrapReads })) return false; + if (request.turnId !== input.turnId || !hasCursorDeniedCommand({ notices: input.notices, runId: input.runId, turnId: input.turnId, requestId: input.expectedRequestId, toolCallId: input.expectedToolCallId, commandSha256: cursorDeniedCommand(input.path).commandSha256, bootstrapReadProof: input.bootstrapReadProof })) return false; const choices = request.choices; if (!Array.isArray(choices)) return false; const keys = choices.map(value => record(value).key); diff --git a/tests/runner-e2e/cursor-native-evidence.test.ts b/tests/runner-e2e/cursor-native-evidence.test.ts index b2c257c9f22..818da87cb7c 100644 --- a/tests/runner-e2e/cursor-native-evidence.test.ts +++ b/tests/runner-e2e/cursor-native-evidence.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { expect, it } from "vitest"; import { cursorDeniedCommand, hasCursorDeniedCommand, hasCursorCancellation, readCursorToolEvidence, type CursorToolNotice } from "./cursor-native-evidence.js"; @@ -85,8 +86,20 @@ it("allows explicit earlier remote bootstrap reads while rejecting unknown kinds const read: CursorToolNotice = { ...rows[0]!, seq: 1, toolCallId: "bootstrap-read", stage: "tool", status: "completed", operation: "read", commandSha256: undefined }; const input = { notices: [read, ...rows], runId: "run", turnId: "turn", requestId: "request", toolCallId: "tool", commandSha256: cursorDeniedCommand("/fixture/denied.txt").commandSha256 }; expect(hasCursorDeniedCommand(input)).toBe(false); - expect(hasCursorDeniedCommand({ ...input, allowBootstrapReads: true })).toBe(true); - for (const operation of [undefined, "edit", "execute"]) expect(hasCursorDeniedCommand({ ...input, allowBootstrapReads: true, notices: [{ ...read, operation }, ...rows] })).toBe(false); - expect(hasCursorDeniedCommand({ ...input, allowBootstrapReads: true, notices: [...rows, { ...read, seq: 20 }] })).toBe(false); - expect(hasCursorDeniedCommand({ ...input, allowBootstrapReads: true, notices: [{ ...read, sessionId: "other" }, ...rows] })).toBe(false); + const actionFile = `.paperclip-eval-action-${"a".repeat(36)}.txt`; + const event = { runId: "run", seq: 2, protocolSchemaVersion: 1, eventType: "tool.execution.completed", payload: { prpEvent: { + schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", runId: "run", turnId: "turn", eventType: "tool.execution.completed", emittedAt: new Date(2).toISOString(), + payload: { schema: "paperclip.tool.execution.v1", executionId: "bootstrap-read", transport: "builtin", operation: "read", target: actionFile, status: "completed" }, + } } }; + read.readTargetSha256 = `sha256:${createHash("sha256").update(actionFile).digest("hex")}`; + const bootstrapReadProof = { actionFile, events: [event] }; + expect(hasCursorDeniedCommand({ ...input, bootstrapReadProof })).toBe(true); + expect(hasCursorDeniedCommand({ ...input, bootstrapReadProof: { actionFile, events: [] } })).toBe(false); + const wrong = structuredClone(event); wrong.payload.prpEvent.payload.target = "unrelated.txt"; + expect(hasCursorDeniedCommand({ ...input, bootstrapReadProof: { actionFile, events: [wrong] } })).toBe(false); + const extra = { ...read, toolCallId: "unrelated", seq: 0 }; + expect(hasCursorDeniedCommand({ ...input, notices: [extra, ...input.notices], bootstrapReadProof })).toBe(false); + for (const operation of [undefined, "edit", "execute"]) expect(hasCursorDeniedCommand({ ...input, bootstrapReadProof, notices: [{ ...read, operation }, ...rows] })).toBe(false); + expect(hasCursorDeniedCommand({ ...input, bootstrapReadProof, notices: [...rows, { ...read, seq: 20 }] })).toBe(false); + expect(hasCursorDeniedCommand({ ...input, bootstrapReadProof, notices: [{ ...read, sessionId: "other" }, ...rows] })).toBe(false); }); diff --git a/tests/runner-e2e/cursor-native-evidence.ts b/tests/runner-e2e/cursor-native-evidence.ts index 2d80edc271e..08c8937ab17 100644 --- a/tests/runner-e2e/cursor-native-evidence.ts +++ b/tests/runner-e2e/cursor-native-evidence.ts @@ -1,15 +1,16 @@ +import { withoutProvenBootstrapReads, type BootstrapReadProof } from "./native-bootstrap-read-proof.js"; import { createHash } from "node:crypto"; import { isAbsolute, posix } from "node:path"; export interface CursorToolNotice { runId: string; sessionId: string; turnId: string; toolCallId: string; seq: number; stage: "tool" | "permission_requested" | "permission_delivered"; - status?: string; operation?: string; commandSha256?: string; requestId?: string; declineOffered?: boolean; outcome?: string; + status?: string; operation?: string; commandSha256?: string; readTargetSha256?: string; requestId?: string; declineOffered?: boolean; outcome?: string; } const rec = (v: unknown): Record => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record : {}; const id = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v) && !v.includes("[REDACTED]"); const enums = { stage: ["tool", "permission_requested", "permission_delivered"], status: ["pending", "in_progress", "completed", "failed"], operation: ["execute", "read"], outcome: ["allow_once", "allow_always", "reject_once", "cancel"] }; -const names = new Set(["stage", "toolCallId", "status", "operation", "commandSha256", "requestId", "declineOffered", "outcome"]); +const names = new Set(["stage", "toolCallId", "status", "operation", "commandSha256", "readTargetSha256", "requestId", "declineOffered", "outcome"]); export function readCursorToolEvidence(rows: readonly unknown[], runId: string): CursorToolNotice[] { const result: CursorToolNotice[] = []; for (const value of rows) { @@ -27,6 +28,7 @@ export function readCursorToolEvidence(rows: readonly unknown[], runId: string): if (!id(fields.toolCallId) || !enums.stage.includes(fields.stage!) || origin.eventType !== fields.stage || origin.method !== (fields.stage === "tool" ? "session/update" : "session/request_permission")) throw new Error("Invalid Cursor evidence origin"); for (const [key, values] of Object.entries(enums)) if (fields[key] !== undefined && !values.includes(fields[key]!)) throw new Error("Invalid Cursor evidence enum"); if (fields.requestId !== undefined && !id(fields.requestId)) throw new Error("Invalid Cursor request identity"); + if (fields.readTargetSha256 !== undefined && !/^sha256:[a-f0-9]{64}$/u.test(fields.readTargetSha256)) throw new Error("Invalid Cursor read digest"); if (fields.commandSha256 !== undefined && !/^sha256:[a-f0-9]{64}$/u.test(fields.commandSha256)) throw new Error("Invalid Cursor command digest"); if (fields.declineOffered !== undefined && !["true", "false"].includes(fields.declineOffered)) throw new Error("Invalid Cursor offered choice"); result.push({ ...fields, runId, sessionId: origin.sessionId, turnId: origin.turnId, seq: row.seq, declineOffered: fields.declineOffered === "true" } as CursorToolNotice); @@ -42,13 +44,12 @@ export function cursorDeniedCommand(path: string) { return { command, commandSha256: `sha256:${createHash("sha256").update(command).digest("hex")}` }; } export function hasCursorDeniedCommand(input: { - notices: readonly CursorToolNotice[]; runId: string; turnId: string; requestId: string; toolCallId: string; commandSha256: string; allowBootstrapReads?: boolean; + notices: readonly CursorToolNotice[]; runId: string; turnId: string; requestId: string; toolCallId: string; commandSha256: string; bootstrapReadProof?: BootstrapReadProof; }): boolean { const origin = input.notices.find(row => row.stage === "tool" && row.status === "pending" && row.operation === "execute" && row.toolCallId === input.toolCallId && row.commandSha256 === input.commandSha256); - // Only explicit native reads before the tested command may belong to the - // remote action-file bootstrap. Undefined kinds or later work remain failures. - const notices = input.notices.filter(row => !(input.allowBootstrapReads && origin && row.operation === "read" && row.seq < origin.seq - && row.runId === input.runId && row.turnId === input.turnId && row.sessionId === origin.sessionId)); + let notices: readonly CursorToolNotice[]; + try { notices = origin ? withoutProvenBootstrapReads(input.notices, origin, input.bootstrapReadProof) : input.notices; } + catch { return false; } const requests = notices.filter(row => row.stage === "permission_requested"); if (requests.length !== 1) return false; const request = requests[0]!; diff --git a/tests/runner-e2e/cursor-native-flow.test.ts b/tests/runner-e2e/cursor-native-flow.test.ts index 5c7170bffc4..7e1b531f5ec 100644 --- a/tests/runner-e2e/cursor-native-flow.test.ts +++ b/tests/runner-e2e/cursor-native-flow.test.ts @@ -39,7 +39,7 @@ it("awaits owned remote baseline before returning the exact remote denial comman const binding = { companyId: "company", environmentId: "environment", runId: "run", leaseId: "lease", sandboxId: "sandbox", remoteCwd: "/home/daytona/paperclip-workspace", image: `runner@sha256:${"a".repeat(64)}` }; const snapshot = { binding, observedAtMs: 50, complete: true, workspace: {}, targets: { "denied.txt": { absent: true, sha256: null, parent: { dev: "1", ino: "2" }, mutationCount: 0, complete: true } }, watcher: { complete: true, targetMutationCount: 0, workspaceMutationCount: 0 }, processes: { captured: true, root: { pid: 51, startTicks: "3021", bootId: "12345678-1234-1234-1234-123456789abc" }, journal: [], live: [51] } }; const order: string[] = []; - const fixture = { binding, remoteCwd: binding.remoteCwd, snapshot: async (label: string) => { order.push(label); await Promise.resolve(); order.push("baseline-ready"); return snapshot; }, finish: async () => snapshot, close: async () => {} }; + const fixture = { binding, actionFile: `.paperclip-eval-action-${"a".repeat(36)}.txt`, remoteCwd: binding.remoteCwd, snapshot: async (label: string) => { order.push(label); await Promise.resolve(); order.push("baseline-ready"); return snapshot; }, finish: async () => snapshot, close: async () => {} }; const prepared = await prepareCursorRemoteAction({ fixture, ...binding, deniedRelative: "denied.txt", prompt: "Perform the actual test" }); order.push("publish-action"); expect(order).toEqual(["before-action-publication", "baseline-ready", "publish-action"]); diff --git a/tests/runner-e2e/cursor-native-flow.ts b/tests/runner-e2e/cursor-native-flow.ts index 8d96dbeafac..2ccb58758c3 100644 --- a/tests/runner-e2e/cursor-native-flow.ts +++ b/tests/runner-e2e/cursor-native-flow.ts @@ -15,7 +15,7 @@ type Row = Record; type Check = { id: string; passed: boolean; detail: string }; export interface CursorRemoteNativeFixture { - binding: CursorRemoteBinding; remoteCwd: string; + binding: CursorRemoteBinding; remoteCwd: string; actionFile: string; snapshot(label: string): Promise; finish(): Promise; close(): Promise; @@ -113,7 +113,7 @@ export async function runCursorNativeFlow(input: { const deniedRelative = `cursor-denied-${nonce}.txt`; let deniedPath = remote ? "" : join(input.workspacePath, deniedRelative); let deniedCommand = remote ? null : cursorDeniedCommand(deniedPath); - let denialNotices: CursorToolNotice[] = []; let denialTurnId = ""; let cancelRequestedAt = NaN; let cancellationProven = false; + let denialNotices: CursorToolNotice[] = []; let denialRunEvents: Row[] = []; let denialTurnId = ""; let cancelRequestedAt = NaN; let cancellationProven = false; const samples: Array<{ phase: string; path: string; absent: boolean; observedAt: number }> = []; const sampleDenied = async (phase: string, retained?: CursorRemoteSnapshot) => { let sample: { phase: string; path: string; absent: boolean; observedAt: number }; @@ -157,7 +157,7 @@ export async function runCursorNativeFlow(input: { finalCheck("denied-absent-after-cleanup", finalSample.absent, "Independent target remains absent after observed provider retirement"); const journal = watch.finish(); finalCheck("continuous-denial-observation", journal.complete && journal.targetMutationCount === 0, "Continuous target watcher observed no create/delete mutation and retained directory identity"); - finalCheck("complete-native-denial-boundary", Boolean(deniedRequest) && hasCursorDenialBoundary({ request: deniedRequest, expectedRequestId: deniedRequest?.requestId ?? "", expectedToolCallId: deniedRequest?.details.toolCallId ?? "", path: deniedPath, allowBootstrapReads: remote, samples, notices: denialNotices, runId: runs[0]?.id ?? "", turnId: denialTurnId }), "Supported native denial preserved the target through all six independent boundaries"); + finalCheck("complete-native-denial-boundary", Boolean(deniedRequest) && hasCursorDenialBoundary({ request: deniedRequest, expectedRequestId: deniedRequest?.requestId ?? "", expectedToolCallId: deniedRequest?.details.toolCallId ?? "", path: deniedPath, bootstrapReadProof: remoteFixture ? { actionFile: remoteFixture.actionFile, events: denialRunEvents } : undefined, samples, notices: denialNotices, runId: runs[0]?.id ?? "", turnId: denialTurnId }), "Supported native denial preserved the target through all six independent boundaries"); await input.evidence("cursor-native-denial-final.json", { request: deniedRequest, samples, notices: denialNotices, commandSha256: deniedCommand!.commandSha256, cancellationProven, cancelRequestedAt, processes, processObservationError, watcher: journal, checks: cleanupChecks }); if (cleanupChecks.some(row => !row.passed)) throw new Error("Cursor native denial cleanup proof is incomplete or observed an effect"); return cleanupChecks; @@ -178,7 +178,7 @@ export async function runCursorNativeFlow(input: { if (design.id === "native-write-deny-reconnect") { await sampleDenied("after-cleanup", snapshot); finalCheck("remote-cancel-terminal", cancellationProven && runs[0]?.status === "cancelled" && issue.status === "in_progress", "Explicit native cancellation remains durable without false task completion"); - finalCheck("remote-no-denied-effect", snapshot.watcher.targetMutationCount === 0 && Boolean(deniedRequest) && hasCursorDenialBoundary({ request: deniedRequest, expectedRequestId: deniedRequest?.requestId ?? "", expectedToolCallId: deniedRequest?.details.toolCallId ?? "", path: deniedPath, allowBootstrapReads: remote, samples, notices: denialNotices, runId: runs[0]?.id ?? "", turnId: denialTurnId }), "Exact denied native command caused no remote file effect through provider retirement"); + finalCheck("remote-no-denied-effect", snapshot.watcher.targetMutationCount === 0 && Boolean(deniedRequest) && hasCursorDenialBoundary({ request: deniedRequest, expectedRequestId: deniedRequest?.requestId ?? "", expectedToolCallId: deniedRequest?.details.toolCallId ?? "", path: deniedPath, bootstrapReadProof: remoteFixture ? { actionFile: remoteFixture.actionFile, events: denialRunEvents } : undefined, samples, notices: denialNotices, runId: runs[0]?.id ?? "", turnId: denialTurnId }), "Exact denied native command caused no remote file effect through provider retirement"); } else if (design.id !== "native-plan-reject-revise-accept") { finalCheck("remote-no-workspace-effects", hasCursorRemoteWorkspaceUnchanged(snapshot, remoteBaseline!), "Native question or cancelled plan caused no remote workspace mutation through retirement"); } @@ -197,7 +197,7 @@ export async function runCursorNativeFlow(input: { if (watch) processes = observeProcesses(); const interactions = await api.get(`/api/issues/${issue.id}/interactions`); const runEvents = runs.length === 1 ? await events(runs[0]!.id) : []; - if (design.id === "native-write-deny-reconnect" && runs.length === 1) denialNotices = readCursorToolEvidence(runEvents, runs[0]!.id); + if (design.id === "native-write-deny-reconnect" && runs.length === 1) { denialRunEvents = runEvents; denialNotices = readCursorToolEvidence(runEvents, runs[0]!.id); } return { issue, runs, interactions, runEvents }; }; const reject = (state: Awaited>) => state.runs.length > 1 ? "Unexpected extra Cursor provider run" : state.runs.some(run => ["failed", "cancelled", "timed_out"].includes(run.status)) ? "Cursor provider run failed" : undefined; @@ -305,7 +305,7 @@ export async function runCursorNativeFlow(input: { check("browser-exact-denial", posted.turnId === event.turnId && posted.requestKind === "permission_approval" && posted.resolution?.action === "decline", "Browser denied the exact native run/request/turn"); const identity = { runId: native.runId, turnId: event.turnId, requestId: request.requestId, method: "session/request_permission" as const, action: "decline" as const }; await pollUntil({ label: "native denial delivered and exact command failed", deadlineAt: input.deadlineAt, load, reject, accept: state => hasDeliveredCursorNativeRequest({ ...identity, events: state.runEvents }) - && hasCursorDeniedCommand({ notices: denialNotices, ...identity, toolCallId: native.toolCallId, commandSha256: deniedCommand!.commandSha256, allowBootstrapReads: remote }) }); + && hasCursorDeniedCommand({ notices: denialNotices, ...identity, toolCallId: native.toolCallId, commandSha256: deniedCommand!.commandSha256, bootstrapReadProof: remoteFixture ? { actionFile: remoteFixture.actionFile, events: denialRunEvents } : undefined }) }); await sampleDenied("after-decision"); cancelRequestedAt = Date.now(); await api.post(`/api/heartbeat-runs/${native.runId}/cancel`); const cancelled = await pollUntil({ label: "explicit native cancellation", deadlineAt: input.deadlineAt, load, diff --git a/tests/runner-e2e/native-bootstrap-read-proof.test.ts b/tests/runner-e2e/native-bootstrap-read-proof.test.ts new file mode 100644 index 00000000000..f13e4ce5240 --- /dev/null +++ b/tests/runner-e2e/native-bootstrap-read-proof.test.ts @@ -0,0 +1,117 @@ +import { createHash } from "node:crypto"; +import { expect, it } from "vitest"; +import { bootstrapReadExecutionId, withoutProvenBootstrapReads, type BootstrapReadNotice } from "./native-bootstrap-read-proof.js"; +const actionFile = `.paperclip-eval-action-${"a".repeat(36)}.txt`; +const origin: BootstrapReadNotice = { runId: "run", sessionId: "session", turnId: "turn", toolCallId: "tested", stage: "tool", operation: "execute", status: "pending", seq: 20 }; +function read(status = "completed", seq = 1): BootstrapReadNotice { return { ...origin, toolCallId: "bootstrap", operation: "read", readTargetSha256: `sha256:${createHash("sha256").update(actionFile).digest("hex")}`, status, seq }; } +function receipt(type = "completed", status = "completed", seq = 2) { + return { runId: "run", seq, protocolSchemaVersion: 1, eventType: `tool.execution.${type}`, payload: { prpEvent: { + schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", runId: "run", turnId: "turn", eventType: `tool.execution.${type}`, emittedAt: new Date(seq).toISOString(), + payload: { schema: "paperclip.tool.execution.v1", executionId: "bootstrap", transport: "builtin", operation: "read", target: actionFile, status }, + } } }; +} +it.each(["completed", "failed"])("accepts an exact %s read even when the first native event is terminal", status => { + expect(withoutProvenBootstrapReads([read(status), origin], origin, { actionFile, events: [receipt("completed", status)] })).toEqual([origin]); +}); +it("accepts a complete started/progressed/failed lifecycle, including failed unpublished-file reads", () => { + const rows = [read("pending", 1), read("in_progress", 3), read("failed", 5), origin]; + const events = [receipt("started", "running", 2), receipt("progressed", "running", 4), receipt("completed", "failed", 6)]; + expect(withoutProvenBootstrapReads(rows, origin, { actionFile, events })).toEqual([origin]); +}); +it.each([ + "wrong-target", "missing-target", "foreign-run", "foreign-turn", "foreign-row-run", "wrong-source", "wrong-schema", "wrong-payload", "wrong-transport", "wrong-operation", "wrong-event-type", "invalid-date", "duplicate-row", "duplicate-seq", "conflicting-target", "missing-row", "missing-terminal", "late-row", "unknown-read-origin", +])("rejects %s durable proof", variant => { + const row = receipt(), rows = [row]; const notices = [read(), origin]; const event = row.payload.prpEvent; + if (variant === "wrong-target") event.payload.target = "private.txt"; + if (variant === "missing-target") delete (event.payload as any).target; + if (variant === "foreign-run") event.runId = "other"; + if (variant === "foreign-row-run") row.runId = "other"; + if (variant === "foreign-turn") event.turnId = "other"; + if (variant === "wrong-source") event.sourceKind = "provider"; + if (variant === "wrong-schema") event.schemaVersion = 2; + if (variant === "wrong-payload") event.payload.schema = "unknown"; + if (variant === "wrong-transport") event.payload.transport = "mcp"; + if (variant === "wrong-operation") event.payload.operation = "edit"; + if (variant === "wrong-event-type") event.eventType = "tool.execution.progressed"; + if (variant === "invalid-date") event.emittedAt = "unknown"; + if (variant === "duplicate-row") rows.push(structuredClone(row)); + if (variant === "duplicate-seq") row.seq = 1; + if (variant === "conflicting-target") { const other = receipt("completed", "completed", 3); other.payload.prpEvent.payload.target = "foreign.txt"; rows.push(other); } + if (variant === "missing-row") rows.length = 0; + if (variant === "missing-terminal") { rows[0] = receipt("started", "running"); notices[0] = read("pending"); } + if (variant === "late-row") row.seq = 21; + if (variant === "unknown-read-origin") event.payload.executionId = "unobserved-native-origin"; + expect(() => withoutProvenBootstrapReads(notices, origin, { actionFile, events: rows })).toThrow(/proof/); +}); +it("does not exempt unrelated earlier reads or infer a path from a title", () => { + const unrelated = { ...read(), toolCallId: "private-read", seq: 3 }; + const wrong = receipt("completed", "completed", 4); wrong.payload.prpEvent.payload.executionId = "private-read"; wrong.payload.prpEvent.payload.target = "secrets.txt"; + Object.assign(wrong.payload.prpEvent.payload, { name: `Read ${actionFile}` }); + expect(() => withoutProvenBootstrapReads([read(), unrelated, origin], origin, { actionFile, events: [receipt(), wrong] })).toThrow(); + expect(withoutProvenBootstrapReads([read(), unrelated, origin], origin)).toHaveLength(3); +}); +it("rejects reused native IDs, cross-session notices and ambiguous completion ordering", () => { + for (const notices of [[read(), read("completed", 3), origin], [{ ...read(), sessionId: "other" }, origin], [read(), { ...read(), seq: 30 }, origin]]) { + expect(() => withoutProvenBootstrapReads(notices, origin, { actionFile, events: [receipt()] })).toThrow(); + } + expect(() => withoutProvenBootstrapReads([read(), origin], origin, { actionFile: "guessed-nonce.txt", events: [receipt()] })).toThrow(); +}); +it("preserves later reads and unknown native kinds for the caller's extra-operation rejection", () => { + for (const n of [{ ...read(), seq: 21 }, { ...read(), operation: undefined }, { ...read(), operation: "edit" }]) { + expect(withoutProvenBootstrapReads([n, origin], origin, { actionFile, events: [] })).toEqual([n, origin]); + } +}); +it("mirrors stable ASCII bounds and rejects invalid native identities", () => { + expect(bootstrapReadExecutionId("read:alpha-1")).toBe("read:alpha-1"); + expect(bootstrapReadExecutionId("a".repeat(160))).toBe("a".repeat(160)); + expect(bootstrapReadExecutionId("a".repeat(161))).toBe("acpx-tool-c4b0cf58ad5787f270b446a5eb28905e09d8e0aa06e7f577e27aed951d573406"); + expect(bootstrapReadExecutionId("tool / é")).toBe("acpx-tool-c870bde596691facb0dc508859665ab3ee9de278e7d49adecaab0fa600feaff0"); + expect(bootstrapReadExecutionId("é".repeat(121))).toBe("acpx-tool-9e1e0f61749be86786959ac0384b636ca4b9e00c78e9d77161f0432871edf151"); + for (const id of ["", "a".repeat(241), "read\nnext", "[REDACTED]"]) expect(() => bootstrapReadExecutionId(id)).toThrow(); +}); + +it("accepts omitted terminal path/kind only with the retained single-path origin attestation", () => { + const started = receipt("started", "running", 2), completed = receipt("completed", "failed", 4); + Object.assign(completed.payload.prpEvent.payload, { target: null, operation: "unknown" }); + const notices = [read("pending", 1), read("failed", 3), origin], proof = { actionFile, events: [started, completed] }; + expect(withoutProvenBootstrapReads(notices, origin, proof)).toEqual([origin]); + for (const digest of [undefined, `sha256:${"0".repeat(64)}`]) { + expect(() => withoutProvenBootstrapReads([{ ...notices[0]!, readTargetSha256: digest }, ...notices.slice(1)], origin, proof)).toThrow(); + expect(() => withoutProvenBootstrapReads([notices[0]!, { ...notices[1]!, readTargetSha256: digest }, origin], origin, proof)).toThrow(); + } +}); + +it.each(["cursor", "copilot"] as const)("correlates actual %s passive notices through the public reader and canonical omitted-field updates", async provider => { + const { createCursorToolEvidence } = await import("../../packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.js"); + const { createCopilotToolEvidence } = await import("../../packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.js"); + const { readCursorToolEvidence } = await import("./cursor-native-evidence.js"); + const { readCopilotToolEvidence } = await import("./copilot-evidence.js"); + const create = provider === "cursor" ? createCursorToolEvidence : createCopilotToolEvidence; + for (const variant of ["exact", "wrong-path", "multi-path", "ambiguous-update"]) { + const rows: any[] = []; + const projector = create({ sessionId: "session", turnId: "turn", workingDirectory: "/workspace", active: () => true, + emit: event => rows.push({ runId: "run", seq: rows.length * 2 + 1, protocolSchemaVersion: 1, eventType: event.eventType, payload: { prpEvent: { + schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", runId: "run", turnId: "turn", emittedAt: new Date(1).toISOString(), eventType: event.eventType, payload: event.payload, + } } }), + }); + const path = variant === "wrong-path" ? "private.txt" : actionFile; + projector.tool({ type: "tool_call", tag: "tool_call", toolCallId: "bootstrap", kind: "read", status: "pending", rawInput: variant === "multi-path" ? { paths: [path, "private.txt"] } : { path }, locations: [{ path }] }); + projector.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "bootstrap", status: "failed", ...(variant === "ambiguous-update" ? { rawInput: { path, paths: [path, "private.txt"] } } : {}) }); + const evaluate = () => { + const readNotices = provider === "cursor" ? readCursorToolEvidence(rows, "run") : readCopilotToolEvidence(rows, "run"); + const started = receipt("started", "running", 2), end = receipt("completed", "failed", 4); + Object.assign(end.payload.prpEvent.payload, { target: null, operation: "unknown" }); + return withoutProvenBootstrapReads([...readNotices, origin], origin, { actionFile, events: [...rows, started, end] }); + }; + if (variant === "exact") expect(evaluate()).toEqual([origin]); else expect(evaluate).toThrow(); + } +}); + +it("accepts bounded correlated progress for status-omitting native content updates", () => { + const notices = [read("pending", 1), read("completed", 5), origin]; + const progressed = receipt("progressed", "running", 3); Object.assign(progressed.payload.prpEvent.payload, { target: null, operation: "unknown" }); + const events = [receipt("started", "running", 2), progressed, receipt("completed", "completed", 6)]; + expect(withoutProvenBootstrapReads(notices, origin, { actionFile, events })).toEqual([origin]); + progressed.payload.prpEvent.payload.target = "other.txt"; + expect(() => withoutProvenBootstrapReads(notices, origin, { actionFile, events })).toThrow(); +}); diff --git a/tests/runner-e2e/native-bootstrap-read-proof.ts b/tests/runner-e2e/native-bootstrap-read-proof.ts new file mode 100644 index 00000000000..a103c486965 --- /dev/null +++ b/tests/runner-e2e/native-bootstrap-read-proof.ts @@ -0,0 +1,89 @@ +import { createHash } from "node:crypto"; + +export interface BootstrapReadNotice { + runId: string; sessionId: string; turnId: string; toolCallId: string; seq: number; + stage: string; operation?: string; status?: string; readTargetSha256?: string; +} +export interface BootstrapReadProof { actionFile: string; events: readonly unknown[] } +const record = (value: unknown): Record => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +const hashId = (domain: string, id: string) => `acpx-tool-${createHash("sha256").update(domain).update("tool\0").update(id).digest("hex")}`; +/** Mirror the pinned sidecar identity bound and Rust acpx_opaque_item_id. */ +export function bootstrapReadExecutionId(toolCallId: string): string { + if (!toolCallId || toolCallId.length > 240 || /[\u0000-\u001f\u007f]/u.test(toolCallId) || toolCallId.includes("[REDACTED]")) throw new Error("Invalid bootstrap native tool identity"); + const bounded = Buffer.byteLength(toolCallId) > 240 ? hashId("paperclip.acpx.provider-identity.v1\0", toolCallId) : toolCallId; + return /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u.test(bounded) && Buffer.byteLength(bounded) <= 160 ? bounded : hashId("paperclip.acpx.opaque-item.v1\0", bounded); +} + +/** Exempt only completed reads with the exact single-path native attestation on + * every notice. Canonical PRP retains only the first location and may omit path + * and kind on update, so it cannot establish single-file provenance by itself. + * Validate its correlated lifecycle and reject every conflicting explicit path; + * the passive origin attestation supplies cardinality and retained path proof. + * No target is inferred from titles, output, or the first location alone. */ +export function withoutProvenBootstrapReads( + notices: readonly T[], origin: BootstrapReadNotice, proof?: BootstrapReadProof, +): T[] { + if (!proof) return [...notices]; + const expectedReadDigest = `sha256:${createHash("sha256").update(proof.actionFile).digest("hex")}`; + const invalid = () => new Error("Native bootstrap read lacks exact complete action-file proof"); + if (notices.length > 2048 || proof.events.length > 20_000 || !/^\.paperclip-eval-action-[a-f0-9]{36}\.txt$/u.test(proof.actionFile) || !Number.isSafeInteger(origin.seq)) throw invalid(); + const candidates = notices.filter(n => n.operation === "read" && n.seq < origin.seq); + const groups = new Map(); + const nativeIds = new Map(); + const noticeSeq = new Set(); + for (const n of candidates) { + if (n.readTargetSha256 !== expectedReadDigest || n.stage !== "tool" || n.runId !== origin.runId || n.turnId !== origin.turnId || n.sessionId !== origin.sessionId + || !Number.isSafeInteger(n.seq) || n.seq < 0 || noticeSeq.has(n.seq)) throw invalid(); + noticeSeq.add(n.seq); + const executionId = bootstrapReadExecutionId(n.toolCallId); + if (nativeIds.has(executionId) && nativeIds.get(executionId) !== n.toolCallId) throw invalid(); + nativeIds.set(executionId, n.toolCallId); + const group = groups.get(executionId) ?? []; group.push(n); groups.set(executionId, group); + } + const receipts = new Map>(); + const receiptSeq = new Set(); + for (const value of proof.events) { + const row = record(value), event = record(record(row.payload).prpEvent), payload = record(event.payload); + const type = row.eventType; + const family = typeof type === "string" && type.startsWith("tool.execution."); + const correlated = groups.has(payload.executionId); + // An earlier canonical read without its native origin must not disappear. + const earlierRead = family && payload.operation === "read" && row.seq < origin.seq; + if (!correlated && !earlierRead) continue; + if (!family || !["tool.execution.started", "tool.execution.progressed", "tool.execution.completed"].includes(type) + || row.runId !== origin.runId || row.protocolSchemaVersion !== 1 || event.schema !== "paperclip.prp.event.v1" + || event.schemaVersion !== 1 || event.sourceKind !== "runner" || event.runId !== origin.runId || event.turnId !== origin.turnId + || event.eventType !== type || !Number.isFinite(Date.parse(event.emittedAt)) + || payload.schema !== "paperclip.tool.execution.v1" || payload.transport !== "builtin" || !["read", "unknown"].includes(payload.operation) + || (payload.target !== null && payload.target !== proof.actionFile) || !groups.has(payload.executionId) + || !Number.isSafeInteger(row.seq) || row.seq < 0 || row.seq >= origin.seq || receiptSeq.has(row.seq) || noticeSeq.has(row.seq)) throw invalid(); + receiptSeq.add(row.seq); + const group = receipts.get(payload.executionId) ?? []; + group.push({ seq: row.seq, type, status: payload.status, operation: payload.operation }); receipts.set(payload.executionId, group); + } + for (const [id, native] of groups) { + const canonical = receipts.get(id) ?? []; + native.sort((a, b) => a.seq - b.seq); canonical.sort((a, b) => a.seq - b.seq); + if (canonical.length < native.length || canonical.length === 0) throw invalid(); + for (let index = 0; index < native.length; index++) { + const n = native[index]!; + const terminal = n.status === "completed" || n.status === "failed"; + if ((!terminal && n.status !== "pending" && n.status !== "in_progress") || (terminal && index !== native.length - 1)) throw invalid(); + } + if (!["completed", "failed"].includes(native.at(-1)!.status ?? "")) throw invalid(); + for (let index = 0; index < canonical.length; index++) { + const c = canonical[index]!, last = index === canonical.length - 1; + if ((index === 0 && c.operation !== "read") + || (last ? c.type !== "tool.execution.completed" || c.status !== native.at(-1)!.status + : c.status !== "running" || c.type !== (index === 0 ? "tool.execution.started" : "tool.execution.progressed"))) throw invalid(); + } + // Native shape/content updates may omit status. PRP emits a progressed row + // while the passive observer retains its attestation without another notice. + // Such rows remain subject to exact identity, target, order and terminal checks. + // Any later notice for this origin means the supposed bootstrap was not + // complete before the tested action, even if an earlier terminal existed. + if (notices.some(n => n.toolCallId === native[0]!.toolCallId && !native.includes(n))) throw invalid(); + } + const exempt = new Set(candidates); + return notices.filter(n => !exempt.has(n)); +}