diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.test.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.test.ts index 0f73f7c201..2c13f997be 100644 --- a/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.test.ts @@ -27,14 +27,21 @@ it("cannot invent origin from permission or a terminal delta", () => { const s = setup(); const delivered = s.p.permission(request, "request", ["decline"]); delivered!("reject_once"); s.p.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "tool", kind: "execute", status: "failed", rawInput: initial.rawInput }); expect(s.events).toHaveLength(0); }); -it.each(["foreign-session", "changed-command", "changed-kind", "reused-origin", "duplicate-permission"])("fails qualification closed for %s", variant => { +it.each([ + ["foreign-session", "permission_session_mismatch"], ["changed-command", "conflicting_permission_input"], + ["changed-kind", "changed_tool_kind"], ["reused-origin", "reused_tool_origin"], ["duplicate-permission", "ambiguous_permission"], +])("fails qualification closed for %s with a bounded reason", (variant, reason) => { const s = setup(); s.p.tool(initial); if (variant === "foreign-session") s.p.permission({ raw: { ...request.raw, sessionId: "foreign" } }, "request", ["decline"]); if (variant === "changed-command") s.p.permission({ raw: { ...request.raw, toolCall: { ...request.raw.toolCall, rawInput: { command: "different" } } } }, "request", ["decline"]); if (variant === "changed-kind") s.p.tool({ ...initial, tag: "tool_call_update", kind: "edit" }); if (variant === "reused-origin") s.p.tool(initial); if (variant === "duplicate-permission") { s.p.permission(request, "request", ["decline"]); s.p.permission(request, "request2", ["decline"]); } - expect(s.fields().at(-1).stage).toBe("evidence_incomplete"); expect(s.unavailable()).toBe(true); + expect(s.fields().at(-1)).toEqual({ stage: "evidence_incomplete", toolCallId: "unavailable", reason }); expect(s.unavailable()).toBe(true); + const count = s.events.length; + s.p.tool({ ...initial, tag: "tool_call_update", status: "completed" }); + expect(s.p.permission(request, "later-request", ["decline"])).toBeUndefined(); + expect(s.events).toHaveLength(count); }); it("ignores inactive turns and never lets observation errors undo a delivered decision", () => { const s = setup(); s.p.tool(initial); const delivered = s.p.permission(request, "request", ["decline"]); s.stop(); delivered!("reject_once"); expect(s.events).toHaveLength(2); @@ -50,10 +57,34 @@ it("bounds retained tool origins", () => { it("rejects commands missing from their original frame and bounded oversized input", () => { for (const command of [undefined, "x".repeat(64 * 1024 + 1)]) { const s = setup(); s.p.tool({ ...initial, rawInput: { command } }); - expect(s.fields().at(-1).stage).toBe("evidence_incomplete"); + expect(s.fields().at(-1)).toEqual({ stage: "evidence_incomplete", toolCallId: "unavailable", reason: "missing_command_origin" }); } }); +it.each([ + ["invalid_permission_tool_identity", { raw: { ...request.raw, toolCall: { ...request.raw.toolCall, toolCallId: "private-argument\ncanary" } } }, "request"], + ["invalid_request_identity", request, "private-request\ncanary"], + ["invalid_permission_kind", { raw: { ...request.raw, toolCall: { ...request.raw.toolCall, kind: "private-kind\ncanary" } } }, "request"], +] as const)("distinguishes %s without retaining malformed provider input", (reason, nativeRequest, requestId) => { + const s = setup(); + expect(s.p.permission(nativeRequest, requestId, ["decline"])).toBeUndefined(); + expect(s.fields()).toEqual([{ stage: "evidence_incomplete", toolCallId: "unavailable", reason }]); + expect(JSON.stringify(s.events)).not.toContain("canary"); +}); + +it("never trusts an external error's message, reason, or cause as a diagnostic code", () => { + let fail = true; + const s = setup(() => { + if (!fail) return; + fail = false; + throw Object.assign(new Error("private-argument-canary", { cause: new Error("private-cause-canary") }), { reason: "permission_session_mismatch" }); + }); + s.p.tool(initial); + expect(s.fields()).toEqual([{ stage: "evidence_incomplete", toolCallId: "unavailable", reason: "projection_failed" }]); + expect(JSON.stringify(s.events)).not.toMatch(/canary|permission_session_mismatch/); + expect(s.unavailable()).toBe(true); +}); + it("preserves execute denial evidence after valid edit and read permission inputs", () => { const s = setup(); for (const kind of ["edit", "read"]) { 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 b7f9979317..7e299e5ab7 100644 --- a/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.ts +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.ts @@ -9,6 +9,17 @@ const digest = (s: string) => createHash("sha256").update(s).digest("hex"); type Fields = Record; 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 }; +type ProjectionFailureReason = + | "evidence_limit" | "tool_limit" | "missing_command_origin" | "reused_tool_origin" + | "invalid_tool_kind" | "changed_tool_kind" | "changed_or_missing_command" + | "conflicting_permission_kind" | "conflicting_permission_input" + | "permission_session_mismatch" | "invalid_permission_tool_identity" | "invalid_request_identity" + | "ambiguous_permission" | "invalid_permission_kind" | "unknown_outcome"; +// Only locally constructed failures may publish a reason. Provider or sink +// exception messages, causes, and similarly named fields are never evidence. +class ProjectionFailure extends Error { + constructor(readonly reason: ProjectionFailureReason) { super(reason); } +} /** Passive, bounded evidence from the active prompt iterator only. Cursor's * permission frame omits rawInput; only the same tool's original tool_call may @@ -21,7 +32,7 @@ export function createCursorToolEvidence(binding: { let sequence = 0; let broken = false; function notice(stage: string, toolCallId: string, fields: Fields, method = "session/update") { if (!binding.active() || !id(binding.sessionId) || !id(binding.turnId)) return; - if (++sequence > 2048 && stage !== "evidence_incomplete") throw new Error("Evidence bound exceeded"); + if (++sequence > 2048 && stage !== "evidence_incomplete") throw new ProjectionFailure("evidence_limit"); const itemId = `cursor-evidence-${digest(`${binding.sessionId}:${binding.turnId}`).slice(0, 24)}-${sequence}`; binding.emit({ eventType: "provider.notice.recorded", itemId, payload: redactPaperclipSemanticValue({ schema: "paperclip.provider.notice.v1", noticeId: itemId, severity: "info", category: "cursor_tool_evidence_v1", scope: "turn", recoverable: true, userActionable: false, @@ -32,10 +43,11 @@ export function createCursorToolEvidence(binding: { } function safely(action: () => T): T | undefined { if (broken) return; - try { return action(); } catch { + try { return action(); } catch (error) { broken = true; // Reporting failures cannot rewrite a response already delivered to ACP. - try { notice("evidence_incomplete", "unavailable", { reason: "projection_failed" }); } catch { /* Sink unavailable. */ } + const reason = error instanceof ProjectionFailure ? error.reason : "projection_failed"; + try { notice("evidence_incomplete", "unavailable", { reason }); } catch { /* Sink unavailable. */ } try { binding.unavailable?.(); } catch { /* Diagnostics remain passive. */ } } } @@ -47,10 +59,10 @@ export function createCursorToolEvidence(binding: { function flush(toolId: string) { const state = tools.get(toolId), permission = permissions.get(toolId); if (!state || !permission) return; - if (permission.kind !== undefined && permission.kind !== state.kind) throw new Error("Conflicting permission kind"); + if (permission.kind !== undefined && permission.kind !== state.kind) throw new ProjectionFailure("conflicting_permission_kind"); // Other native tools have non-command input (for example path/content). // Their bounded identity/status evidence must not disable later shell proof. - if (state.kind === "execute" && permission.hasInput && (!permission.commandSha256 || permission.commandSha256 !== state.commandSha256)) throw new Error("Conflicting permission input"); + if (state.kind === "execute" && permission.hasInput && (!permission.commandSha256 || permission.commandSha256 !== state.commandSha256)) throw new ProjectionFailure("conflicting_permission_input"); const fields: Fields = { requestId: permission.requestId, declineOffered: permission.declineOffered }; if (state.kind === "execute" || state.kind === "read") fields.operation = state.kind; if (state.commandSha256) fields.commandSha256 = state.commandSha256; @@ -64,19 +76,19 @@ export function createCursorToolEvidence(binding: { const toolId = call.toolCallId; let state = tools.get(toolId); if (!state) { if (call.tag !== "tool_call") return; - if (tools.size >= 256) throw new Error("Tool bound exceeded"); + if (tools.size >= 256) throw new ProjectionFailure("tool_limit"); state = {}; tools.set(toolId, state); - if (call.kind === "execute" && !command(call)) throw new Error("Missing command origin"); - } else if (call.tag === "tool_call") throw new Error("Reused tool origin"); - if (call.kind !== undefined && (!id(call.kind) || call.kind.length > 64)) throw new Error("Invalid tool kind"); + if (call.kind === "execute" && !command(call)) throw new ProjectionFailure("missing_command_origin"); + } else if (call.tag === "tool_call") throw new ProjectionFailure("reused_tool_origin"); + if (call.kind !== undefined && (!id(call.kind) || call.kind.length > 64)) throw new ProjectionFailure("invalid_tool_kind"); if (typeof call.kind === "string") { - if (state.kind && state.kind !== call.kind) throw new Error("Changed tool kind"); + if (state.kind && state.kind !== call.kind) throw new ProjectionFailure("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"); + if (!hash || (call.tag !== "tool_call" && !state.commandSha256) || (state.commandSha256 && state.commandSha256 !== hash)) throw new ProjectionFailure("changed_or_missing_command"); state.commandSha256 = hash; } if (!["pending", "in_progress", "completed", "failed"].includes(String(call.status))) return; @@ -89,15 +101,17 @@ export function createCursorToolEvidence(binding: { return safely(() => { if (!binding.active()) return; const raw = rec(rec(request).raw), call = rec(raw.toolCall); - if (raw.sessionId !== binding.sessionId || !id(call.toolCallId) || !id(requestId)) throw new Error("Unbound permission"); + if (raw.sessionId !== binding.sessionId) throw new ProjectionFailure("permission_session_mismatch"); + if (!id(call.toolCallId)) throw new ProjectionFailure("invalid_permission_tool_identity"); + if (!id(requestId)) throw new ProjectionFailure("invalid_request_identity"); const toolId = call.toolCallId; - if (permissions.has(toolId) || permissions.size >= 256) throw new Error("Ambiguous permission"); - if (call.kind !== undefined && (!id(call.kind) || call.kind.length > 64)) throw new Error("Invalid permission kind"); + if (permissions.has(toolId) || permissions.size >= 256) throw new ProjectionFailure("ambiguous_permission"); + if (call.kind !== undefined && (!id(call.kind) || call.kind.length > 64)) throw new ProjectionFailure("invalid_permission_kind"); const state: Permission = { requestId, kind: call.kind as string | undefined, hasInput: call.rawInput !== undefined, commandSha256: command(call), declineOffered: offeredActions.includes("decline") }; permissions.set(toolId, state); flush(toolId); return (outcome: string) => { safely(() => { if (state.outcome) return; - if (!["allow_once", "allow_always", "reject_once", "cancel"].includes(outcome)) throw new Error("Unknown outcome"); + if (!["allow_once", "allow_always", "reject_once", "cancel"].includes(outcome)) throw new ProjectionFailure("unknown_outcome"); state.outcome = outcome; flush(toolId); }); }; }); diff --git a/tests/runner-e2e/native-active-stop-evidence.ts b/tests/runner-e2e/native-active-stop-evidence.ts index ab8008afee..0225da2f2a 100644 --- a/tests/runner-e2e/native-active-stop-evidence.ts +++ b/tests/runner-e2e/native-active-stop-evidence.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { canonicalJson } from "../../packages/shared/src/portability-hash.js"; import { hasAcpxNativeOrigin } from "./acpx-native-origin.js"; +import { isValidNativePrpEnvelope } from "./native-event-envelope.js"; import { assertCopilotRemoteRetirement, copilotRemoteDeniedSample, type CopilotRemoteSnapshot } from "./copilot-protection-evidence.js"; import { readCopilotToolEvidence } from "./copilot-evidence.js"; import { readCursorToolEvidence } from "./cursor-native-evidence.js"; @@ -39,8 +40,8 @@ function canonicalRows(events: readonly unknown[], scope: ActiveStopScope) { const seen = new Set(), source = new Set(); for (const row of rows) { const e = rec(rec(row.payload).prpEvent); - fail(row.companyId === scope.companyId && row.runId === scope.runId && row.protocolSchemaVersion === 1 - && e.schema === "paperclip.prp.event.v1" && e.schemaVersion === 1 && e.sourceKind === "runner" && e.runId === scope.runId + fail(row.companyId === scope.companyId && row.runId === scope.runId && isValidNativePrpEnvelope(e, row.protocolSchemaVersion) + && e.sourceKind === "runner" && e.runId === scope.runId && e.eventType === row.eventType && Number.isSafeInteger(row.seq) && row.seq > 0 && !seen.has(row.seq) && id(e.sourceInstanceId) && Number.isSafeInteger(e.sourceSeq) && e.sourceSeq > 0 && e.sourceEventId === `${e.sourceInstanceId}:${e.runId}:${e.sourceSeq}` && !source.has(e.sourceEventId), "invalid/duplicate/foreign canonical row"); diff --git a/tests/runner-e2e/native-active-stop.test.ts b/tests/runner-e2e/native-active-stop.test.ts index adc1175529..76b58e35e2 100644 --- a/tests/runner-e2e/native-active-stop.test.ts +++ b/tests/runner-e2e/native-active-stop.test.ts @@ -44,8 +44,51 @@ function fixture(provider: ActiveStopProvider = "copilot") { const frame = (row: Row) => row.payload.prpEvent; const payload = (row: Row) => frame(row).payload; function settled() { const f = fixture(); const pending = f.pending(); f.settle(); return { f, pending, read: () => readActiveStopSettlement({ ...f.state(), pending, dispatchMonotonicNs: (BigInt(pending.observedMonotonicNs) + 1n).toString() }) }; } +function withSessionPrefix(provider: ActiveStopProvider = "cursor") { + const f = fixture(provider); + // The retained failed attempt contained v1 session.started followed by these + // v2 session events before its v1 tool/permission events. Keep that shape; + // this synthetic fixture supplies the otherwise required native tool proof. + for (const row of f.events) { + row.seq += 30; frame(row).sourceSeq += 3; + frame(row).sourceEventId = `source:run:${frame(row).sourceSeq}`; + } + const prefix = [f.row(17, "session.started", {}), + f.row(19, "session.capabilities.updated", { sessionGoals: null }), + f.row(21, "session.goal.snapshot", { goal: null, workingNow: false })]; + prefix.forEach((row, index) => { + const e = frame(row); e.turnId = null; e.sourceSeq = index + 1; e.sourceEventId = `source:run:${index + 1}`; + if (index > 0) { row.protocolSchemaVersion = 2; e.schema = "paperclip.prp.event.v2"; e.schemaVersion = 2; } + }); + f.events.unshift(...prefix); + return f; +} describe("definitely active native permission Stop", () => { + it.each(["cursor", "copilot"] as const)("accepts the mixed v1/v2 session prefix before strict %s pending proof", provider => { + const f = withSessionPrefix(provider); + expect(f.pending()).toMatchObject({ requestId: "request", toolCallId: "tool", permissionSourceSeq: 6, requestSourceSeq: 7 }); + }); + it.each([ + ["schema/version mismatch", (f: ReturnType) => { frame(f.events[1]!).schemaVersion = 1; }], + ["row/envelope mismatch", (f: ReturnType) => { f.events[1]!.protocolSchemaVersion = 1; }], + ["unknown schema", (f: ReturnType) => { frame(f.events[1]!).schema = "paperclip.prp.event.v3"; frame(f.events[1]!).schemaVersion = f.events[1]!.protocolSchemaVersion = 3; }], + ["foreign session source", (f: ReturnType) => { frame(f.events[1]!).sourceKind = "provider"; }], + ] as const)("still rejects %s in a mixed-version stream", (_label, mutate) => { + const f = withSessionPrefix(); mutate(f); expect(f.pending).toThrow("invalid/duplicate/foreign canonical row"); + }); + it("does not let a valid v2 prefix hide the retained Cursor incomplete-evidence failure", () => { + const f = withSessionPrefix(); + const notice = f.events.find(row => row.eventType === "provider.notice.recorded")!; + payload(notice).provenance.eventType = "evidence_incomplete"; + payload(notice).details = Object.entries({ stage: "evidence_incomplete", toolCallId: "unavailable", reason: "projection_failed" }).map(([name, value]) => ({ name, value })); + expect(f.pending).toThrow("Cursor evidence is explicitly incomplete"); + }); + it("still requires the original native tool ID on the durable card after a valid v2 prefix", () => { + const f = withSessionPrefix(); + delete payload(f.events.find(row => row.eventType === "runtime_request.created")!).request.details.toolCallId; + expect(f.pending).toThrow("native notice/card identity mismatch"); + }); it.each(["cursor", "copilot"] as const)("binds %s's unanswered callback to cancelled provider settlement and caller-owned Stop", provider => { const f = fixture(provider), pending = f.pending(); f.settle(); expect(readActiveStopSettlement({ ...f.state(), pending, dispatchMonotonicNs: (BigInt(pending.observedMonotonicNs) + 1n).toString() })).toMatchObject({