diff --git a/doc/architecture/runner-copilot-capabilities.md b/doc/architecture/runner-copilot-capabilities.md index a9093af0c1..c9d535e87c 100644 --- a/doc/architecture/runner-copilot-capabilities.md +++ b/doc/architecture/runner-copilot-capabilities.md @@ -1,6 +1,26 @@ # Copilot 1.0.88 rich ACP capability audit -Current source candidate (2026-10-01): **Copilot profile v10 is unqualified**. +Current source candidate (2026-10-01): **Copilot profile v11 is unqualified**. +The authenticated Daytona v10 attempt exposed two independent receipt-boundary +failures. Generic JWT redaction removed the public receipt schema name from a +notice detail. Separately, a legitimate completion omitted optional fields that +the strict result validator fills before forwarding; its original argument hash +therefore differed from the proposed canonical result. The failed attempt remains +failed even though its owned processes, sandbox and IPC retired cleanly. + +Receipt v2 preserves the original `inputSha256` and separately records +`normalizedInputSha256` at the same invocation's actual validated forwarding +boundary. The capture commits only after that emission succeeds; absent or +invalid capture remains null. Native result matching still requires the exact +invocation-owned receipt and all original call/input/result hashes. Acceptance +must independently match the normalized digest and the unique proposed/accepted +result bodies. Transport return alone never proves acceptance. The server +preserves only the fixed schema literal inside a validated receipt notice; +adjacent credentials and JWT-shaped values remain redacted. Retained v10 warm +sessions are incompatible. Native executable bytes are unchanged; new runtime +packs and fresh local/Daytona qualification are required. + +Historical source candidate (2026-10-01): **Copilot profile v10 was unqualified**. Admitted Paperclip MCP calls append a bounded `paperclip.semantic_tool_receipt.v1` text block after the original result blocks. It records the operation, call-ID hash, canonical argument hash, result hash and transport outcome. An invocation diff --git a/doc/architecture/runner-rich-acp-capabilities.md b/doc/architecture/runner-rich-acp-capabilities.md index a1088b0ae2..33da6b7648 100644 --- a/doc/architecture/runner-rich-acp-capabilities.md +++ b/doc/architecture/runner-rich-acp-capabilities.md @@ -1,6 +1,16 @@ # Rich ACP integration and qualification report -Current source checkpoint (2026-10-01): **Cursor v10, Copilot v10 and Pi v10 +Current source checkpoint (2026-10-01): **Cursor v10, Copilot v11 and Pi v10 +remain unqualified**. Copilot receipt v2 distinguishes original provider arguments +from the strictly validated input actually forwarded for completion. A same-call +capture binds both hashes without reconstructing omitted defaults in the grader. +The server's JWT heuristic now preserves only the fixed schema literal in a +validated semantic-receipt notice. Native call/result correlation and independent +canonical acceptance remain required. The failed v10 Daytona attempt is retained; +fresh v11 runtime packaging and local/Daytona evidence are still required. +The [Copilot inventory](runner-copilot-capabilities.md) records both boundaries. + +Historical source checkpoint (2026-10-01): **Cursor v10, Copilot v10 and Pi v10 remain unqualified**. Cursor now shares the bounded native tool-ID mapping across sidecar activity and permissions, then deterministically joins that key to the canonical opaque execution ID. Copilot correlates native tool results with diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index d5f31a01d7..e362169031 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -223,7 +223,7 @@ impl AcpxProviderDescriptor { "1.0.88", None, None, - "sha256:c6741d7e49cd1af4ecad9e07181ef96305bde48b56dce2958547f2f066666231", + "sha256:e3057abcc2e6eb0db3a5c683a15e32159ac3caa78ba3d4c2a5d9221929e867dd", ), "grok" => ( "grok-4.7", @@ -2812,7 +2812,7 @@ mod tests { "copilot", "@github/copilot", "1.0.88", - "sha256:c6741d7e49cd1af4ecad9e07181ef96305bde48b56dce2958547f2f066666231", + "sha256:e3057abcc2e6eb0db3a5c683a15e32159ac3caa78ba3d4c2a5d9221929e867dd", None, None, "explicit-model", @@ -2914,6 +2914,13 @@ mod tests { assert!(wrong_agent.validate(&context()).is_err()); } if agent == "copilot" { + let mut previous_v10 = value.clone(); + previous_v10["commandDigest"] = json!( + "sha256:c6741d7e49cd1af4ecad9e07181ef96305bde48b56dce2958547f2f066666231" + ); + let previous_v10: AcpxProviderDescriptor = + serde_json::from_value(previous_v10).unwrap(); + assert!(previous_v10.validate(&context()).is_err()); let mut previous_v6 = value.clone(); previous_v6["commandDigest"] = json!( "sha256:0fe49c2f8a2b144344d0de745fed1e4568df305de3a26c3a121dec21c8d4b751" diff --git a/packages/paperclip-runner/scripts/build-copilot-distribution.mjs b/packages/paperclip-runner/scripts/build-copilot-distribution.mjs index bcb5226194..bd45ea6217 100644 --- a/packages/paperclip-runner/scripts/build-copilot-distribution.mjs +++ b/packages/paperclip-runner/scripts/build-copilot-distribution.mjs @@ -7,7 +7,7 @@ import { COPILOT_VERSION, materializePinnedCopilotBinary, resolveCopilotDistribu const MAX_ARCHIVE_BYTES = 128 * 1024 * 1024; const MAX_EXPANDED_BYTES = 384 * 1024 * 1024; -const PROFILE_DIGEST = "sha256:c6741d7e49cd1af4ecad9e07181ef96305bde48b56dce2958547f2f066666231"; +const PROFILE_DIGEST = "sha256:e3057abcc2e6eb0db3a5c683a15e32159ac3caa78ba3d4c2a5d9221929e867dd"; /** Build-time only; outputRoot is the exact runner-owned platform asset directory. */ export async function buildPinnedCopilotDistribution({ outputRoot, platform = process.platform, architecture = process.arch }, { fetchImpl = fetch } = {}) { diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts index ff0d5f1d0f..4b7a8d977f 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -562,6 +562,7 @@ describe("native backend factory", () => { ["cursor", "../../test/fixtures/cursor-acp/profile-v9-identity.json"], ["copilot", "../../test/fixtures/copilot-profile-v7-identity.json"], ["copilot", "../../test/fixtures/copilot-profile-v9-identity.json"], + ["copilot", "../../test/fixtures/copilot-profile-v10-identity.json"], ["pi", "../../test-fixtures/pi-acp/profile-v9-identity.json"], ] as const)("rejects the exact historical %s identity before runtime startup", (agent, path) => { const historical = JSON.parse(readFileSync(new URL(path, import.meta.url), "utf8")); diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts index e93ac92fcf..08e5982e33 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -1,4 +1,6 @@ -import { appendSemanticToolReceipt } from "../drivers/semantic-tool-receipt.js"; +import { appendSemanticToolReceipt, readNativeSemanticReceipt, semanticInputSha256 } from "../drivers/semantic-tool-receipt.js"; +import { startRunnerToolBridge, type RunnerToolCall } from "../drivers/runner-tool-bridge.js"; +import { validatePrpStructuredRunResult } from "../protocol/replay-contract.js"; import { acpxUsageEstimateNotice, persistedAcpxTurnUsage, persistedCursorUsageNotice } from "../drivers/acpx/usage-accounting.js"; import { stripTypeScriptTypes } from "node:module"; import { cursorPlanToolIdentity, cursorToolIdentity } from "../drivers/acpx/cursor-plan-tool-identity.js"; @@ -225,11 +227,59 @@ describe("qualified ACPX runtime sidecar", () => { const receipt = appendSemanticToolReceipt({ tool: "get_task_context", callId: "1", arguments: {} }, { content: [{ type: "text", text: "{}" }] }).receipt; captured(receipt); expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ payload: { category: "paperclip_semantic_tool_receipt_v1", provenance: { sessionId: "session-a", turnId: "turn-a" } } }); + expect(events[0]).toMatchObject({ payload: { category: "paperclip_semantic_tool_receipt_v2", provenance: { sessionId: "session-a", turnId: "turn-a" } } }); active = false; captured(receipt); expect(events).toHaveLength(1); for (const agent of ["cursor", "codex", "pi"]) expect(create({ agent, tools: [] }, evidence, () => undefined).captureSemanticReceipt).toBeUndefined(); }); + it.each(["forwarded", "emit-failed"])("binds native v2 receipts to actual sidecar-normalized input: %s", async mode => { + const raw = { reportedWorkDisposition: "done", summary: "Complete", evidence: [], verification: [], + completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [], remainingWork: [] } }; + const validated = validatePrpStructuredRunResult(raw); + if (!validated.ok) throw new Error("Invalid fixture"); + const tools = new Map(), emitted: any[] = [], notices: any[] = []; + const projector = createCopilotToolEvidence({ sessionId: "session", turnId: "test-turn", workingDirectory: "/workspace", + active: () => true, emit: event => { validateAcpxRichEvent(event); notices.push(event); } }); + const handler = loadWaitForTool({ tools, emitted, validate: validatePrpStructuredRunResult, + ...(mode === "emit-failed" ? { emit: () => { throw new Error("fixture emission failed"); } } : {}) }); + const bridge = await startRunnerToolBridge({ handler, captureSemanticReceipt: () => projector.captureSemanticReceipt() }); + try { + projector.tool({ type: "tool_call", tag: "tool_call", toolCallId: "native-call", kind: "other", status: "pending", rawInput: raw }); + const response = fetch(bridge.url, { method: "POST", headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "paperclip_finish", arguments: raw } }) }); + if (mode === "forwarded") { + await vi.waitFor(() => expect(tools.has("3")).toBe(true)); + expect(emitted).toEqual([{ callId: "3", operationId: "paperclip_finish", input: validated.result }]); + // The receipt hashes the actual emitted input, not a second normalization. + tools.get("3").settle({ accepted: true }); + } + const body = await (await response).json(); + const receipt = readNativeSemanticReceipt({ contents: body.result.content }); + expect(receipt).toMatchObject({ schema: "paperclip.semantic_tool_receipt.v2", inputSha256: semanticInputSha256(raw), + normalizedInputSha256: mode === "forwarded" ? semanticInputSha256(emitted[0].input) : null, + outcome: mode === "forwarded" ? "returned" : "error" }); + expect(semanticInputSha256(raw)).not.toBe(semanticInputSha256(validated.result)); + projector.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "native-call", status: mode === "forwarded" ? "completed" : "failed", + rawOutput: { contents: body.result.content } }); + const details = Object.fromEntries(notices.at(-1).payload.details.map((d: any) => [d.name, d.value])); + expect(details).toMatchObject({ semanticInputSha256: semanticInputSha256(raw), + semanticNormalizedInputSha256: mode === "forwarded" ? semanticInputSha256(emitted[0].input) : "null" }); + expect(notices.find(event => event.payload.category === "paperclip_semantic_tool_receipt_v2").payload.details).toHaveLength(8); + } finally { + for (const pending of tools.values()) pending.cleanup(); + await bridge.close(); + } + }); + + it("does not capture normalized authority when actual sidecar validation fails", async () => { + const emitted: unknown[] = [], capture = vi.fn(() => vi.fn()); + const wait = loadWaitForTool({ tools: new Map(), emitted, validate: validatePrpStructuredRunResult }); + await expect(wait({ tool: "paperclip_finish", callId: "bad", arguments: {}, signal: new AbortController().signal, + captureNormalizedInput: capture })).rejects.toThrow("ACPX semantic result failed PRP schema validation"); + expect(capture).not.toHaveBeenCalled(); + expect(emitted).toEqual([]); + }); + it("passes only validated Pi native boundaries and history through the real text sanitizer", () => { const source = readFileSync(fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)), "utf8"); const start = source.indexOf(' if (event.type === "text_delta") {', source.indexOf("function sanitizeRuntimeEvent")); @@ -1011,7 +1061,9 @@ class SidecarProcess { function loadWaitForTool(input: { tools: Map; emitted: unknown[]; -}): (call: { callId: string; tool: string; arguments: Record; signal: AbortSignal }) => Promise { + validate?: typeof validatePrpStructuredRunResult; + emit?: () => void; +}): (call: RunnerToolCall) => Promise { const source = readFileSync( fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)), "utf8", @@ -1034,13 +1086,13 @@ function loadWaitForTool(input: { (value: string) => value, input.tools, "test-turn", - (_eventType: string, payload: unknown) => input.emitted.push(payload), + (_eventType: string, payload: unknown) => { input.emit?.(); input.emitted.push(payload); }, "paperclip_finish", "paperclip_block", - (argumentsValue: unknown) => ({ + input.validate ?? ((argumentsValue: unknown) => ({ ok: true, result: argumentsValue, - }), + })), (value: unknown) => value, (value: unknown) => value, 512, diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index ff4dd8e3de..789ec0d2f4 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -717,6 +717,7 @@ async function waitForTool(call: RunnerToolCall): Promise { // This is the same roundtrip used by ordinary dynamic tools; emitting a // local semantic_result here would let an invalid review handoff appear // accepted before the server has checked it. + const commitNormalizedInput = call.captureNormalizedInput?.(validation.result); emit( "runtime.tool_called", { @@ -726,6 +727,7 @@ async function waitForTool(call: RunnerToolCall): Promise { }, activeTurnId, ); + commitNormalizedInput?.(); return await new Promise((settle, reject) => { const abort = () => { const pending = tools.get(callId); diff --git a/packages/paperclip-runner/src/contracts/native-execution.test.ts b/packages/paperclip-runner/src/contracts/native-execution.test.ts index 857e8d341e..746eed8632 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -296,7 +296,7 @@ describe("NativeExecutionInputV1", () => { })).toThrow("eventExpiryDays"); }); - it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => { + it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => { const provider = { kind: "acpx", agent: "pi", @@ -328,7 +328,7 @@ describe("NativeExecutionInputV1", () => { profile: provider.profile, }); expect(parseNativeExecutionInput(parsed)).toEqual(parsed); - for (const unsupportedVersion of [0, 11, 1.5, "10", null]) { + for (const unsupportedVersion of [0, 12, 1.5, "11", null]) { expect(() => parseNativeExecutionInput({ ...input, session: { ...input.session, driverKind: "acpx_runtime" }, diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index d256400c03..16d57ed888 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -91,7 +91,7 @@ export interface NativeAcpxProfileSnapshot { protocolVersion: 1; acpxVersion: "0.13.1"; agent: NativeAcpxAgent; - agentProfileVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10; + agentProfileVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11; agentServerPackage: string; agentServerVersion: string; agentRuntimePackage: string | null; @@ -608,7 +608,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput || profile.protocolVersion !== 1 || profile.acpxVersion !== "0.13.1" || profile.agent !== provider.agent - || (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3 && profile.agentProfileVersion !== 4 && profile.agentProfileVersion !== 5 && profile.agentProfileVersion !== 6 && profile.agentProfileVersion !== 7 && profile.agentProfileVersion !== 8 && profile.agentProfileVersion !== 9 && profile.agentProfileVersion !== 10) + || (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3 && profile.agentProfileVersion !== 4 && profile.agentProfileVersion !== 5 && profile.agentProfileVersion !== 6 && profile.agentProfileVersion !== 7 && profile.agentProfileVersion !== 8 && profile.agentProfileVersion !== 9 && profile.agentProfileVersion !== 10 && profile.agentProfileVersion !== 11) ) { throw new NativeExecutionInputError("input.provider.profile does not match the qualified ACPX v1 profile"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts index e0775fb741..90a381a580 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts @@ -26,6 +26,48 @@ import type { import type { AcpxRecoveryWorkspaceLease } from "./runtime-sandbox.js"; describe("Codex ACPX harness driver", () => { + it("captures the direct driver's normalized input only when the exact proposal is retained", async () => { + const fixture = driverFixture({ agent: "copilot", model: "explicit-test-model", providerPolicy: { readOnly: true } }, { runtimeEvents: [] }); + const session = await fixture.driver.openSession({ runId: "run-normalized-receipt", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); + await session.startTurn({ message: { text: "Complete" } }); + const { schema: _, attentionRequests: __, artifacts: ___, ...raw } = completedResult(); + const commit = vi.fn(), capture = vi.fn((value: unknown) => { expect(commit).not.toHaveBeenCalled(); return commit; }); + const handler = fixture.hostOptions!.semanticTools!.handler; + await expect(handler({ tool: PRP_COMPLETION_TOOL_NAME, callId: "finish", arguments: raw, signal: new AbortController().signal, + captureNormalizedInput: capture })).resolves.toMatchObject({ accepted: true }); + expect(capture).toHaveBeenCalledExactlyOnceWith(completedResult()); + expect(commit).toHaveBeenCalledOnce(); + const repeatedCapture = vi.fn(() => vi.fn()); + await expect(handler({ tool: PRP_COMPLETION_TOOL_NAME, callId: "different-call-same-result", arguments: raw, signal: new AbortController().signal, + captureNormalizedInput: repeatedCapture })).resolves.toMatchObject({ accepted: true }); + expect(repeatedCapture).not.toHaveBeenCalled(); + const invalidCapture = vi.fn(() => vi.fn()); + await expect(handler({ tool: PRP_COMPLETION_TOOL_NAME, callId: "invalid", arguments: {}, signal: new AbortController().signal, + captureNormalizedInput: invalidCapture })).rejects.toThrow("Invalid semantic run result"); + expect(invalidCapture).not.toHaveBeenCalled(); + fixture.finishTurn({ status: "completed" }); + const events = await collectUntil(session.events(), "turn.completed"); + expect(events.find(event => event.eventType === "run.result.proposed")?.payload).toEqual(capture.mock.calls[0]![0]); + await session.close({ reason: "same-invocation normalization verified" }); + }); + it("does not commit a direct normalized input digest when proposal retention is backpressured", async () => { + const fixture = driverFixture({ agent: "copilot", model: "explicit-test-model", providerPolicy: { readOnly: true } }, { + maxBufferedEvents: 4, terminalEventReserve: 0, + runtimeEvents: Array.from({ length: 8 }, (_, n) => ({ type: "text_delta" as const, stream: "output" as const, text: `bounded-${n}` })), + }); + const session = await fixture.driver.openSession({ runId: "run-normalized-pressure", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); + await session.startTurn({ message: { text: "Complete" } }); + await vi.waitFor(async () => expect((await session.transcript!()).eventCount).toBeGreaterThanOrEqual(4)); + const commit = vi.fn(), capture = vi.fn(() => commit); + await expect(fixture.hostOptions!.semanticTools!.handler({ tool: PRP_COMPLETION_TOOL_NAME, callId: "finish", arguments: completedResult(), + signal: new AbortController().signal, captureNormalizedInput: capture })).rejects.toThrow("event consumer must drain"); + expect(capture).toHaveBeenCalledOnce(); + expect(commit).not.toHaveBeenCalled(); + fixture.finishTurn({ status: "completed" }); + const events = await collectUntil(session.events(), "turn.completed"); + expect(events.some(event => event.eventType === "run.result.proposed")).toBe(false); + await session.close({ reason: "unretained input has no digest" }); + }); it.each(["copilot", "cursor", "pi", "codex"] as const)("scopes optional semantic receipts to the actual %s invocation turn", async agent => { const fixture = driverFixture({ agent, model: "explicit-test-model", providerPolicy: { readOnly: true } }, { runtimeEvents: [] }); const session = await fixture.driver.openSession({ runId: "run-receipt", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); @@ -37,17 +79,17 @@ describe("Codex ACPX harness driver", () => { oldCallback?.(bound.receipt); fixture.finishTurn({ status: "completed" }); const firstEvents = await collectUntil(session.events(), "turn.completed"); - const receipts = firstEvents.filter(e => e.eventType === "provider.notice.recorded" && e.payload.category === "paperclip_semantic_tool_receipt_v1"); + const receipts = firstEvents.filter(e => e.eventType === "provider.notice.recorded" && e.payload.category === "paperclip_semantic_tool_receipt_v2"); expect(receipts).toHaveLength(agent === "copilot" ? 1 : 0); if (agent === "copilot") expect(receipts[0]).toMatchObject({ runId: "run-receipt", turnId: first.turnId, payload: { provenance: { sessionId: "backend-1", turnId: first.turnId } } }); const transcript = JSON.parse(JSON.stringify(await session.transcript!())); for (const event of transcript.events) validatePrpEvent(event); - expect(transcript.events.filter((e: PrpEvent) => e.payload.category === "paperclip_semantic_tool_receipt_v1")).toEqual(receipts); + expect(transcript.events.filter((e: PrpEvent) => e.payload.category === "paperclip_semantic_tool_receipt_v2")).toEqual(receipts); await session.startTurn({ message: { role: "user", text: "Second" } }); oldCallback?.(bound.receipt); fixture.finishTurn({ status: "completed" }); const secondEvents = await collectUntil(session.events(), "turn.completed"); - expect(secondEvents.filter(e => e.payload.category === "paperclip_semantic_tool_receipt_v1")).toEqual([]); + expect(secondEvents.filter(e => e.payload.category === "paperclip_semantic_tool_receipt_v2")).toEqual([]); await session.close({ reason: "receipt turn scope verified" }); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts index 03fdf234fc..6ad8b809b5 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -1185,6 +1185,7 @@ class CodexAcpxSession implements HarnessSession { this.#semanticFingerprint === null || (claimsLaterTurn && !repeatsPendingTransfer) ) { + const commitNormalizedInput = call.captureNormalizedInput?.(validation.result); if ( !this.#emit("run.result.proposed", validation.result, { turnId, @@ -1196,6 +1197,7 @@ class CodexAcpxSession implements HarnessSession { "the event consumer must drain provider events before a semantic result can be accepted", ); } + commitNormalizedInput?.(); if (claimsLaterTurn) { // A reaffirming retry does not own the durable result until its // provider turn completes successfully. A failed or interrupted diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index b1d0a1114b..ba44b155e0 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -23,6 +23,13 @@ const SEMANTIC_RECEIPT_SOURCES = [ ["../semantic-tool-receipt.ts", "semanticReceiptSourceSha256"], ["../runner-tool-bridge.ts", "semanticBridgeSourceSha256"], ["copilot-tool-evidence.ts", "toolEvidenceSourceSha256"], + ["../../cli/acpx-runtime-sidecar.ts", "semanticSidecarSourceSha256"], + ["codex-acpx-driver.ts", "semanticDirectDriverSourceSha256"], + ["../../protocol/replay-contract.ts", "semanticValidationSourceSha256"], + ["../../protocol/result-normalization.ts", "semanticNormalizationSourceSha256"], + ["../../contracts/completion-result.ts", "semanticCompletionContractSourceSha256"], + ["../../protocol/generated/standalone-validators.ts", "semanticValidatorsSourceSha256"], + ["../../protocol/generated/schema-bundle.ts", "semanticSchemaBundleSourceSha256"], ] as const; // Walk executable imports, not just the projector's immediate dependencies. @@ -51,12 +58,13 @@ async function permissionPolicyClosure(extraClassifierImport = false): Promise ({ verifyNativeAcpxInstallation: vi.fn() })); describe("Copilot build-owned installation", () => { - it("admits the current v10 declaration and binds its source policy, receipts and patch hashes", () => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v10-identity.json", import.meta.url), "utf8")); + it("admits the current v11 declaration and binds its source policy, receipts and patch hashes", () => { + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v11-identity.json", import.meta.url), "utf8")); expect(identity.declaration.systemInstructionDelivery).toBe(COPILOT_SYSTEM_INSTRUCTION_DELIVERY); expect(identity.declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1"); expect(identity.declaration.permissionContextContract).toBe(COPILOT_PERMISSION_CONTEXT_CONTRACT); - expect(identity.declaration.semanticToolReceiptContract).toBe("paperclip.semantic_tool_receipt.v1"); + expect(identity.declaration.semanticToolReceiptContract).toBe("paperclip.semantic_tool_receipt.v2"); + expect(identity.declaration.semanticNormalizedInputContract).toBe("validated-forwarded-input-commit-v1"); expect(identity.declaration.messageIdentityContract).toBe("copilot-native-message-id-v1"); expect(identity.declaration.ownedDistributionDelivery).toBe("COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1"); const inventory = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-message-identity-distributions-1.0.88.json", import.meta.url), "utf8")); @@ -81,7 +89,7 @@ describe("Copilot build-owned installation", () => { expect([...closure].filter(path => !bound.has(path))).toEqual(["negative-control:unbound-policy"]); }); it.each([...PERMISSION_POLICY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES])("changing %s changes the candidate identity", (_path, field) => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v10-identity.json", import.meta.url), "utf8")); + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v11-identity.json", import.meta.url), "utf8")); identity.declaration[field] = "0".repeat(64); const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b))); expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).not.toBe(identity.commandDigest); @@ -122,7 +130,7 @@ describe("Copilot build-owned installation", () => { }); it("rejects changed profile identities before native file access", async () => { vi.mocked(verifyNativeAcpxInstallation).mockClear(); - for (const override of [{ agentServerVersion: "latest" }, { commandDigest: "sha256:untrusted" }, { commandDigest: "sha256:ed39259990cb0efda48d6105d2fa3599aac76873eb75cd80b4d0b179ce0579a6" }, { agent: "cursor" }, { agentProfileVersion: 1 }, { agentProfileVersion: 2 }, { agentProfileVersion: 3 }, { agentProfileVersion: 4 }, { agentProfileVersion: 5 }, { agentProfileVersion: 6 }, { agentProfileVersion: 7 }, { agentProfileVersion: 8 }, { agentProfileVersion: 9 }]) { + for (const override of [{ agentServerVersion: "latest" }, { commandDigest: "sha256:untrusted" }, { commandDigest: "sha256:ed39259990cb0efda48d6105d2fa3599aac76873eb75cd80b4d0b179ce0579a6" }, { agent: "cursor" }, { agentProfileVersion: 1 }, { agentProfileVersion: 2 }, { agentProfileVersion: 3 }, { agentProfileVersion: 4 }, { agentProfileVersion: 5 }, { agentProfileVersion: 6 }, { agentProfileVersion: 7 }, { agentProfileVersion: 8 }, { agentProfileVersion: 9 }, { agentProfileVersion: 10 }]) { await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, ...override } as never)).rejects.toThrow("exact pinned"); } expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled(); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.test.ts index 3dc0c16903..9dcd6e8cdc 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.test.ts @@ -132,13 +132,13 @@ describe("Copilot authoritative semantic receipt correlation", () => { h.projector.tool({ ...tool("native", args, "other"), title: "unrelated display" }); h.projector.captureSemanticReceipt()!(bound.receipt); h.projector.tool(terminal("native", bound.result.content)); - const authoritative = h.events.find(e => e.payload.category === "paperclip_semantic_tool_receipt_v1")!; + const authoritative = h.events.find(e => e.payload.category === "paperclip_semantic_tool_receipt_v2")!; expect(details(authoritative)).toMatchObject({ operationId: operation, callIdentitySha256: bound.receipt.callIdentitySha256, outcome: "returned" }); expect(authoritative.payload.provenance).toMatchObject({ method: "paperclip/semantic_tool_result", sessionId: "session", turnId: "turn" }); expect(details(h.events.at(-1)!)).toMatchObject({ semanticOperationId: operation, semanticOutcome: "returned", semanticResultSha256: bound.receipt.resultSha256 }); expect(JSON.stringify(h.events)).not.toMatch(/PRIVATE|accepted|completionClaim/); }); - it.each(["untrusted", "different-input", "different-result", "foreign-session", "foreign-turn", "duplicate-native", "duplicate-authority", "early-terminal", "wrong-status"])("rejects %s authority", scenario => { + it.each(["untrusted", "different-input", "different-normalized-input", "different-result", "foreign-session", "foreign-turn", "duplicate-native", "duplicate-authority", "early-terminal", "wrong-status"])("rejects %s authority", scenario => { const h = harness(), other = harness(scenario === "foreign-turn" ? "session" : "foreign", scenario === "foreign-turn" ? "other-turn" : "turn"); const bound = make(); h.projector.tool({ ...tool("native", scenario === "different-input" ? {} : args, "other"), title: "paperclip_finish" }); if (scenario === "early-terminal") h.projector.tool(terminal("native", bound.result.content)); @@ -151,6 +151,7 @@ describe("Copilot authoritative semantic receipt correlation", () => { } const output = structuredClone(bound.result.content); if (scenario === "different-result") output[0]!.text = "changed"; + if (scenario === "different-normalized-input") output.at(-1)!.text = JSON.stringify({ ...bound.receipt, normalizedInputSha256: "a".repeat(64) }); h.projector.tool(terminal(scenario === "duplicate-native" ? "second" : "native", output, scenario === "wrong-status" ? "failed" : "completed")); expect(details(h.events.at(-1)!)).not.toHaveProperty("semanticOperationId"); if (scenario === "duplicate-native") expect(h.events.map(details)).toContainEqual(expect.objectContaining({ reason: "semantic_receipt_conflict" })); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts index e2eb8489b8..3c2cbcb6e2 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts @@ -7,7 +7,7 @@ import { safeCopilotEditTarget } from "./copilot-permission-context.js"; const LIMIT = 256; const CATEGORY = "copilot_tool_evidence_v1"; -type Fields = Record; +type Fields = Record; interface Tool { kind?: string; input?: string; fields: Fields; invalid?: boolean; semanticInput?: string; semanticReceipt?: SemanticToolReceipt; read?: SingleReadEvidence } const record = (v: unknown): Record => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record : {}; const identity = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v); @@ -153,6 +153,7 @@ export function createCopilotToolEvidence(binding: { fields.semanticOperationId = candidate.operationId; fields.semanticCallIdentitySha256 = candidate.callIdentitySha256; fields.semanticInputSha256 = candidate.inputSha256; + if (candidate.schema === "paperclip.semantic_tool_receipt.v2") fields.semanticNormalizedInputSha256 = candidate.normalizedInputSha256; fields.semanticResultSha256 = candidate.resultSha256; fields.semanticOutcome = candidate.outcome; } @@ -190,7 +191,7 @@ export function createCopilotToolEvidence(binding: { return; } semanticReceipts.set(parsed.callIdentitySha256, parsed); - notice("semantic_result", undefined, { ...parsed }, "paperclip/semantic_tool_result", "paperclip_semantic_tool_receipt_v1"); + notice("semantic_result", undefined, { ...parsed }, "paperclip/semantic_tool_result", parsed.schema === "paperclip.semantic_tool_receipt.v2" ? "paperclip_semantic_tool_receipt_v2" : "paperclip_semantic_tool_receipt_v1"); }); }; }, tool: (event: unknown) => { safely(() => projection.tool(event)); }, diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index ec7f91e374..35c3a738f0 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -72,10 +72,10 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< }, copilot: { driverKind: ACPX_DRIVER_KIND, protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION, - acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 10, + acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 11, agentServerPackage: "@github/copilot", agentServerVersion: "1.0.88", agentRuntimePackage: null, agentRuntimeVersion: null, - commandDigest: "sha256:c6741d7e49cd1af4ecad9e07181ef96305bde48b56dce2958547f2f066666231", + commandDigest: "sha256:e3057abcc2e6eb0db3a5c683a15e32159ac3caa78ba3d4c2a5d9221929e867dd", // Authenticated discovery has not established a qualification model. Never // turn this empty declaration into a default; callers must select an ID. qualificationModel: "", reportedModelId: "", permissionPolicy: "interactive", diff --git a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts index 3dfbe319e2..1a2a364ad2 100644 --- a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts @@ -33,6 +33,7 @@ describe("ACPX recovery identity", () => { ["copilot", "../../../test/fixtures/copilot-profile-v7-identity.json"], ["copilot", "../../../test/fixtures/copilot-profile-v8-identity.json"], ["copilot", "../../../test/fixtures/copilot-profile-v9-identity.json"], + ["copilot", "../../../test/fixtures/copilot-profile-v10-identity.json"], ["pi", "../../../test-fixtures/pi-acp/profile-v9-identity.json"], ] as const)("rejects retained %s sessions after the execution identity changes", async (agent, path) => { const fixture = await recoveryFixture(); diff --git a/packages/paperclip-runner/src/drivers/runner-tool-bridge.test.ts b/packages/paperclip-runner/src/drivers/runner-tool-bridge.test.ts index f41d95b300..ce9870fd4f 100644 --- a/packages/paperclip-runner/src/drivers/runner-tool-bridge.test.ts +++ b/packages/paperclip-runner/src/drivers/runner-tool-bridge.test.ts @@ -1,4 +1,5 @@ -import { readNativeSemanticReceipt, type SemanticToolReceipt, type SemanticToolResult } from "./semantic-tool-receipt.js"; +import { readNativeSemanticReceipt, semanticInputSha256, type SemanticToolReceipt, type SemanticToolResult } from "./semantic-tool-receipt.js"; +import { validatePrpStructuredRunResult } from "../protocol/replay-contract.js"; import { connect } from "node:net"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -468,6 +469,71 @@ function tool(name: string): Readonly> { } describe("Copilot semantic receipt opt-in", () => { + const rawFinish = { reportedWorkDisposition: "done", summary: "The task is complete.", evidence: [], verification: [], + completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [], remainingWork: [] } }; + it.each(["committed", "not-forwarded", "invalid", "duplicate-capture", "conflicting-capture", "duplicate-commit"])( + "captures only the invocation's exact forwarded normalized input: %s", async mode => { + const receipts: SemanticToolReceipt[] = []; + let capturedInput: unknown; + const bridge = await startRunnerToolBridge({ captureSemanticReceipt: () => receipt => receipts.push(receipt), handler: async call => { + const validation = validatePrpStructuredRunResult(call.arguments); + if (!validation.ok) throw new Error("Invalid fixture input"); + capturedInput = structuredClone(validation.result); + const invalid: Record = {}; invalid.self = invalid; + const commit = call.captureNormalizedInput!(mode === "invalid" ? invalid : validation.result); + if (mode === "duplicate-capture") call.captureNormalizedInput!(validation.result)(); + if (mode === "conflicting-capture") call.captureNormalizedInput!({ ...validation.result, summary: "foreign" })(); + if (mode !== "not-forwarded") commit(); + if (mode === "duplicate-commit") commit(); + // The digest is a snapshot of forwarded input, not a later mutable value. + validation.result.summary = "later mutation"; + return { accepted: true }; + } }); + bridges.push(bridge); + const request = { id: 3, method: "tools/call", params: { name: "paperclip_finish", arguments: rawFinish } }; + const body = await (await rpc(bridge, request)).json(); + expect(receipts).toHaveLength(1); + expect(readNativeSemanticReceipt({ contents: body.result.content })).toEqual(receipts[0]); + expect(receipts[0]).toMatchObject({ schema: "paperclip.semantic_tool_receipt.v2", inputSha256: semanticInputSha256(rawFinish), + normalizedInputSha256: mode === "committed" ? semanticInputSha256(capturedInput) : null }); + expect(semanticInputSha256(rawFinish)).not.toBe(semanticInputSha256(capturedInput)); + expect(await (await rpc(bridge, request)).json()).toEqual(body); + expect(receipts).toHaveLength(1); + }, + ); + it("keeps concurrent call captures separate and ignores captures after settlement", async () => { + const pending = new Map; settle: () => void }>(); + const receipts: SemanticToolReceipt[] = []; + const bridge = await startRunnerToolBridge({ captureSemanticReceipt: () => receipt => receipts.push(receipt), handler: call => new Promise(resolve => { + pending.set(call.callId, { capture: call.captureNormalizedInput!, settle: () => resolve({ accepted: true }) }); + }) }); + bridges.push(bridge); + const first = rpc(bridge, { id: "first", method: "tools/call", params: { name: "paperclip_finish", arguments: rawFinish } }); + const secondInput = { ...rawFinish, summary: "Second call" }; + const second = rpc(bridge, { id: "second", method: "tools/call", params: { name: "paperclip_finish", arguments: secondInput } }); + await vi.waitFor(() => expect(pending.size).toBe(2)); + const normalizedFirst = validatePrpStructuredRunResult(rawFinish); const normalizedSecond = validatePrpStructuredRunResult(secondInput); + if (!normalizedFirst.ok || !normalizedSecond.ok) throw new Error("Invalid fixture"); + pending.get("second")!.capture(normalizedSecond.result)(); pending.get("second")!.settle(); + const secondReceipt = readNativeSemanticReceipt({ contents: (await (await second).json()).result.content }); + expect(secondReceipt).toMatchObject({ inputSha256: semanticInputSha256(secondInput), normalizedInputSha256: semanticInputSha256(normalizedSecond.result) }); + pending.get("second")!.capture(normalizedFirst.result)(); + pending.get("first")!.capture(normalizedFirst.result)(); pending.get("first")!.settle(); + const firstReceipt = readNativeSemanticReceipt({ contents: (await (await first).json()).result.content }); + expect(firstReceipt).toMatchObject({ inputSha256: semanticInputSha256(rawFinish), normalizedInputSha256: semanticInputSha256(normalizedFirst.result) }); + expect(receipts).toEqual([secondReceipt, firstReceipt]); + }); + it("does not accept model metadata as normalized authority or widen non-Copilot calls", async () => { + const modelMetadata = { normalizedInputSha256: "a".repeat(64), captureNormalizedInput: "forged" }; + const withEvidence = await startRunnerToolBridge({ tools: [tool("documents.read")], captureSemanticReceipt: () => () => {}, handler: async call => { + expect(call.captureNormalizedInput).toBeUndefined(); return modelMetadata; + } }); bridges.push(withEvidence); + const body = await (await rpc(withEvidence, { id: 1, method: "tools/call", params: { name: "documents.read", arguments: modelMetadata } })).json(); + expect(readNativeSemanticReceipt({ contents: body.result.content })).toHaveProperty("normalizedInputSha256", null); + const plain = await startRunnerToolBridge({ handler: async call => { expect(call.captureNormalizedInput).toBeUndefined(); return { accepted: true }; } }); bridges.push(plain); + const plainBody = await (await rpc(plain, { id: 2, method: "tools/call", params: { name: "paperclip_finish", arguments: rawFinish } })).json(); + expect(readNativeSemanticReceipt({ contents: plainBody.result.content })).toBeNull(); + }); it.each(["small", "chunked", "tagged", "error"])("preserves %s result encoding and captures scope before dispatch", async encoding => { const receipts: SemanticToolReceipt[] = []; let resolve!: (value: unknown) => void; diff --git a/packages/paperclip-runner/src/drivers/runner-tool-bridge.ts b/packages/paperclip-runner/src/drivers/runner-tool-bridge.ts index ff59048654..137bf439db 100644 --- a/packages/paperclip-runner/src/drivers/runner-tool-bridge.ts +++ b/packages/paperclip-runner/src/drivers/runner-tool-bridge.ts @@ -1,5 +1,5 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; -import { appendSemanticToolReceipt, semanticCanonicalJson as canonicalJson, type SemanticToolReceipt } from "./semantic-tool-receipt.js"; +import { appendSemanticToolReceipt, semanticCanonicalJson as canonicalJson, semanticInputSha256, type SemanticToolReceipt } from "./semantic-tool-receipt.js"; import { createServer, type IncomingMessage, @@ -27,6 +27,9 @@ export interface RunnerToolCall { callId: string; arguments: unknown; signal: AbortSignal; + /** Internal, invocation-owned evidence. Snapshot before forwarding; commit only + * after that exact normalized input is forwarded. Never supplied by the model. */ + captureNormalizedInput?: (input: unknown) => (() => void); } export interface RunnerToolBridgeOptions { @@ -327,10 +330,32 @@ async function handleRequest( const controller = existing === undefined ? new AbortController() : undefined; let observeReceipt: ((receipt: SemanticToolReceipt) => void) | undefined; if (!existing) { try { observeReceipt = context.captureSemanticReceipt?.(); } catch { /* Optional evidence cannot prevent dispatch. */ } } + let receiptSealed = false; + let normalizationCaptured = false; + let normalizationInvalid = false; + let normalizedInputSha256: string | null = null; + const captureNormalizedInput = (input: unknown): (() => void) => { + if (receiptSealed || controller?.signal.aborted) return () => {}; + if (normalizationCaptured) { normalizationInvalid = true; return () => {}; } + normalizationCaptured = true; + let digest: string; + try { + if (!isRecord(input) || Buffer.byteLength(JSON.stringify(input)) > context.maxBodyBytes) throw new Error("Invalid normalized input"); + digest = semanticInputSha256(input); + } catch { normalizationInvalid = true; return () => {}; } + let committed = false; + return () => { + if (receiptSealed || controller?.signal.aborted) return; + if (committed) { normalizationInvalid = true; return; } + committed = true; + if (!normalizationInvalid) normalizedInputSha256 = digest; + }; + }; const withReceipt = (result: RunnerToolCallResult): RunnerToolCallResult => { + receiptSealed = true; if (!context.captureSemanticReceipt) return result; try { - const bound = appendSemanticToolReceipt({ tool, callId, arguments: args }, result); + const bound = appendSemanticToolReceipt({ tool, callId, arguments: args, normalizedInputSha256: normalizationInvalid ? null : normalizedInputSha256 }, result); try { observeReceipt?.(bound.receipt); } catch { /* Evidence cannot change the tool outcome. */ } return bound.result; } catch { return result; } @@ -345,6 +370,8 @@ async function handleRequest( callId, arguments: structuredClone(args), signal: controller!.signal, + ...(context.captureSemanticReceipt && (tool === PRP_COMPLETION_TOOL_NAME || tool === PRP_BLOCK_TOOL_NAME) + ? { captureNormalizedInput } : {}), }), ) .then((result) => successfulToolResult(tool, callId, result)), diff --git a/packages/paperclip-runner/src/drivers/semantic-tool-receipt.test.ts b/packages/paperclip-runner/src/drivers/semantic-tool-receipt.test.ts index 758a51cbf5..5392c5d829 100644 --- a/packages/paperclip-runner/src/drivers/semantic-tool-receipt.test.ts +++ b/packages/paperclip-runner/src/drivers/semantic-tool-receipt.test.ts @@ -43,4 +43,20 @@ describe("bounded semantic receipt carrier", () => { expect(parseSemanticToolReceipt({ ...receipt, inputSha256: "a".repeat(65) })).toBeNull(); expect(parseSemanticToolReceipt({ ...receipt, outcome: "accepted" })).toBeNull(); }); + it("keeps raw and forwarded input identities separate and versions historical receipts honestly", () => { + const normalizedInputSha256 = semanticInputSha256({ ...call.arguments, schema: "paperclip.run_result.v1" }); + const { receipt, result } = appendSemanticToolReceipt({ ...call, normalizedInputSha256 }, original); + expect(receipt).toMatchObject({ schema: "paperclip.semantic_tool_receipt.v2", normalizedInputSha256, + inputSha256: semanticInputSha256(call.arguments) }); + expect(receipt.inputSha256).not.toBe(normalizedInputSha256); + expect(readNativeSemanticReceipt({ contents: result.content })).toEqual(receipt); + expect(appendSemanticToolReceipt(call, original).receipt).toHaveProperty("normalizedInputSha256", null); + const { normalizedInputSha256: _, ...legacyFields } = receipt as typeof receipt & { normalizedInputSha256: unknown }; + const legacy = { ...legacyFields, schema: "paperclip.semantic_tool_receipt.v1" }; + expect(parseSemanticToolReceipt(legacy)).toEqual(legacy); + expect(parseSemanticToolReceipt({ ...legacy, normalizedInputSha256 })).toBeNull(); + expect(parseSemanticToolReceipt({ ...receipt, normalizedInputSha256: "null" })).toBeNull(); + expect(parseSemanticToolReceipt({ ...receipt, normalizedInputSha256: "a".repeat(65) })).toBeNull(); + expect(parseSemanticToolReceipt({ ...legacyFields, schema: "paperclip.semantic_tool_receipt.v2" })).toBeNull(); + }); }); diff --git a/packages/paperclip-runner/src/drivers/semantic-tool-receipt.ts b/packages/paperclip-runner/src/drivers/semantic-tool-receipt.ts index b67cf0c651..0d69026156 100644 --- a/packages/paperclip-runner/src/drivers/semantic-tool-receipt.ts +++ b/packages/paperclip-runner/src/drivers/semantic-tool-receipt.ts @@ -1,10 +1,10 @@ import { createHash } from "node:crypto"; -export const SEMANTIC_RECEIPT_SCHEMA = "paperclip.semantic_tool_receipt.v1"; +export const SEMANTIC_RECEIPT_SCHEMA = "paperclip.semantic_tool_receipt.v2"; +const LEGACY_SEMANTIC_RECEIPT_SCHEMA = "paperclip.semantic_tool_receipt.v1"; export const MAX_SEMANTIC_RECEIPT_BYTES = 2048; const MAX_NATIVE_BYTES = 256 * 1024; -export interface SemanticToolReceipt { - schema: typeof SEMANTIC_RECEIPT_SCHEMA; +interface SemanticToolReceiptFields { operationId: string; callIdentitySha256: string; inputSha256: string; @@ -12,6 +12,10 @@ export interface SemanticToolReceipt { /** Transport outcome only: a returned value may explicitly reject a request. */ outcome: "returned" | "error"; } +export type SemanticToolReceipt = SemanticToolReceiptFields & ( + | { schema: typeof LEGACY_SEMANTIC_RECEIPT_SCHEMA } + | { schema: typeof SEMANTIC_RECEIPT_SCHEMA; normalizedInputSha256: string | null } +); export interface SemanticToolResult { content: Array<{ type: "text"; text: string }>; isError?: boolean; @@ -26,23 +30,31 @@ export function semanticCanonicalJson(value: unknown): string { } export const semanticInputSha256 = (value: unknown): string => hash(semanticCanonicalJson(value)); export function parseSemanticToolReceipt(value: unknown): SemanticToolReceipt | null { - if (!object(value) || Object.keys(value).sort().join(",") !== "callIdentitySha256,inputSha256,operationId,outcome,resultSha256,schema" - || value.schema !== SEMANTIC_RECEIPT_SCHEMA + if (!object(value)) return null; + const legacy = value.schema === LEGACY_SEMANTIC_RECEIPT_SCHEMA; + const keys = legacy ? "callIdentitySha256,inputSha256,operationId,outcome,resultSha256,schema" + : "callIdentitySha256,inputSha256,normalizedInputSha256,operationId,outcome,resultSha256,schema"; + if (Object.keys(value).sort().join(",") !== keys + || (!legacy && value.schema !== SEMANTIC_RECEIPT_SCHEMA) || typeof value.operationId !== "string" || !/^[A-Za-z0-9_.:-]{1,256}$/.test(value.operationId) || ![value.callIdentitySha256, value.inputSha256, value.resultSha256].every(v => typeof v === "string" && /^[a-f0-9]{64}$/.test(v)) + || (!legacy && value.normalizedInputSha256 !== null && (typeof value.normalizedInputSha256 !== "string" || !/^[a-f0-9]{64}$/.test(value.normalizedInputSha256))) || (value.outcome !== "returned" && value.outcome !== "error") || Buffer.byteLength(JSON.stringify(value)) > MAX_SEMANTIC_RECEIPT_BYTES) return null; - return { schema: SEMANTIC_RECEIPT_SCHEMA, operationId: value.operationId, + const fields: SemanticToolReceiptFields = { operationId: value.operationId, callIdentitySha256: value.callIdentitySha256 as string, inputSha256: value.inputSha256 as string, resultSha256: value.resultSha256 as string, outcome: value.outcome }; + return legacy ? { schema: LEGACY_SEMANTIC_RECEIPT_SCHEMA, ...fields } + : { schema: SEMANTIC_RECEIPT_SCHEMA, ...fields, normalizedInputSha256: value.normalizedInputSha256 as string | null }; } function resultDigest(result: SemanticToolResult): string { return semanticInputSha256({ content: result.content, isError: result.isError === true }); } /** Append, never rewrite, the original admitted result encoding and content. */ -export function appendSemanticToolReceipt(call: { tool: string; callId: string; arguments: unknown }, result: SemanticToolResult) { +export function appendSemanticToolReceipt(call: { tool: string; callId: string; arguments: unknown; normalizedInputSha256?: string | null }, result: SemanticToolResult) { const receipt = parseSemanticToolReceipt({ schema: SEMANTIC_RECEIPT_SCHEMA, operationId: call.tool, callIdentitySha256: hash(call.callId), inputSha256: semanticInputSha256(call.arguments), + normalizedInputSha256: call.normalizedInputSha256 ?? null, resultSha256: resultDigest(result), outcome: result.isError ? "error" : "returned" }); if (!receipt) throw new Error("Invalid semantic receipt identity"); return { receipt, result: { ...result, content: [...result.content, { type: "text" as const, text: JSON.stringify(receipt) }] } }; diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v11-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v11-identity.json new file mode 100644 index 0000000000..e92b7c8d8d --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v11-identity.json @@ -0,0 +1,41 @@ +{ + "commandDigest": "sha256:e3057abcc2e6eb0db3a5c683a15e32159ac3caa78ba3d4c2a5d9221929e867dd", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 11, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a", + "upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0", + "patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "messageIdentityContract": "copilot-native-message-id-v1", + "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", + "permissionContextContract": "copilot-edit-permission-context-v1", + "permissionContextSourceSha256": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224", + "permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "687270d7bbe3dccce6c2fed8664e76cda724b43e545cb153b9f54b018de52927", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "8a43ebafb7a85dd1714ed6945670472666dcb63e8af32b4a74644163fa0e370d", + "toolEvidenceSourceSha256": "4b517eed447615b3cc820af348ccc360978175149caa0bbc838d1f78332abe75", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "6c7dedca55f0f031570349b010ede1cd8015c25e9a52afac01ce9de8beb966c2", + "semanticDirectDriverSourceSha256": "31e36917ee3592fa6d8f4b86632129fdffae20c45aed2e0e367ccf523e064f88", + "semanticValidationSourceSha256": "039fca52266fe21ba74ca64660d21228314d8ed737b19ba67941bfb25086e4d5", + "semanticNormalizationSourceSha256": "b1ce59251bf9c671f66e0e5173646190b9a2d1bfc5bdb7fb123824213986b92b", + "semanticCompletionContractSourceSha256": "00a684f9aea87f68eabf0974681c52461bc308631a1252a89c4ed087d7158b28", + "semanticValidatorsSourceSha256": "950b49b233d8dd62ff858398becfc55f0a181160cb8a434d9221b5c17816867c", + "semanticSchemaBundleSourceSha256": "bb55e18c5563bf5ec226e301c3399229304f295e986865e96554f193535368f2" + } +} diff --git a/server/src/__tests__/redaction.test.ts b/server/src/__tests__/redaction.test.ts index f9814a8382..ec5b545de7 100644 --- a/server/src/__tests__/redaction.test.ts +++ b/server/src/__tests__/redaction.test.ts @@ -10,7 +10,112 @@ import { sanitizeRecord, } from "../redaction.js"; +import { createCopilotToolEvidence } from "../../../packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.js"; +import { appendSemanticToolReceipt } from "../../../packages/paperclip-runner/src/drivers/semantic-tool-receipt.js"; + +function receiptNotice(version: 1 | 2 = 1): Record { + return { + schema: "paperclip.provider.notice.v1", noticeId: `copilot-evidence-${"a".repeat(24)}-1`, + severity: "info", category: `paperclip_semantic_tool_receipt_v${version}`, scope: "turn", + recoverable: true, userActionable: false, summary: "Paperclip returned a semantic tool result.", + provenance: { method: "paperclip/semantic_tool_result", eventType: "semantic_result", sessionId: "session", turnId: "turn" }, + details: Object.entries({ stage: "semantic_result", schema: `paperclip.semantic_tool_receipt.v${version}`, + operationId: "finish_task", callIdentitySha256: "a".repeat(64), inputSha256: "b".repeat(64), + resultSha256: "c".repeat(64), outcome: "returned", ...(version === 2 ? { normalizedInputSha256: "d".repeat(64) } : {}), + }).map(([name, value]) => ({ name, value })), + }; +} +const receiptSchemaValue = (notice: Record) => notice.details.find((detail: any) => detail.name === "schema").value; + describe("redaction", () => { + it("preserves the actual Copilot receipt producer discriminator through nested durable redaction", () => { + const events: Array> = []; + const projector = createCopilotToolEvidence({ sessionId: "session", turnId: "turn", workingDirectory: "/workspace", + active: () => true, emit: event => events.push(event) }); + const receipt = appendSemanticToolReceipt({ tool: "finish_task", callId: "call", arguments: {} }, + { content: [{ type: "text", text: "accepted" }] }).receipt; + expect(receipt.schema).toBe("paperclip.semantic_tool_receipt.v2"); + projector.captureSemanticReceipt()!(receipt); + expect(events).toHaveLength(1); + expect(events[0]!.payload.category).toBe("paperclip_semantic_tool_receipt_v2"); + expect(events[0]!.payload.details).toHaveLength(8); + const input = { prpEvent: { schema: "paperclip.prp.event.v1", schemaVersion: 1, + eventType: "provider.notice.recorded", payload: events[0]!.payload } }; + expect(redactEventPayload(input)).toEqual(input); + expect(redactEventPayload(redactEventPayload(input))).toEqual(input); + }); + + it.each([1, 2] as const)("preserves only validated v%s receipt schema literals", version => { + const notice = receiptNotice(version); + expect(redactEventPayload(notice)).toEqual(notice); + for (const outcome of ["returned", "error"]) { + notice.details.find((d: any) => d.name === "outcome").value = outcome; + if (version === 2) notice.details.find((d: any) => d.name === "normalizedInputSha256").value = "null"; + expect(redactEventPayload(notice)).toEqual(notice); + } + expect(redactEventPayload({ value: `paperclip.semantic_tool_receipt.v${version}` })) + .toEqual({ value: REDACTED_EVENT_VALUE }); + }); + + it.each([ + ["wrong category", (n: Record) => { n.category = "copilot_tool_evidence_v1"; }], + ["wrong schema", (n: Record) => { n.schema = "paperclip.provider.native.v1"; }], + ["wrong scope", (n: Record) => { n.scope = "session"; }], + ["wrong provenance", (n: Record) => { n.provenance.method = "session/update"; }], + ["missing provenance", (n: Record) => { delete n.provenance.turnId; }], + ["unknown provenance field", (n: Record) => { n.provenance.extra = "safe"; }], + ["oversized identity", (n: Record) => { n.provenance.sessionId = "x".repeat(241); }], + ["wrong stage", (n: Record) => { n.details[0].value = "tool"; }], + ["unknown receipt schema", (n: Record) => { n.details[1].value = "paperclip.semantic_tool_receipt.v99"; }], + ["version mismatch", (n: Record) => { n.details[1].value = "paperclip.semantic_tool_receipt.v2"; }], + ["duplicate detail", (n: Record) => { n.details[2] = { ...n.details[1] }; }], + ["unknown detail", (n: Record) => { n.details[2].name = "unknown"; }], + ["extra detail", (n: Record) => { n.details.push({ name: "extra", value: "safe" }); }], + ["extra detail property", (n: Record) => { n.details[1].extra = "safe"; }], + ["nonstring hash", (n: Record) => { n.details[3].value = 123; }], + ["malformed hash", (n: Record) => { n.details[3].value = "bad"; }], + ["malformed operation", (n: Record) => { n.details[2].value = "bad operation"; }], + ["unknown outcome", (n: Record) => { n.details[6].value = "accepted"; }], + ] as const)("does not exempt receipt schema in %s context", (_label, mutate) => { + const notice = receiptNotice(); mutate(notice); + expect(receiptSchemaValue(redactEventPayload(notice)!)).toBe(REDACTED_EVENT_VALUE); + }); + + it("does not restore JWTs or adjacent secrets through receipt-shaped data", () => { + const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature12345678"; + const notice = receiptNotice(); + notice.details.find((d: any) => d.name === "operationId").value = jwt; + notice.provenance.sessionId = jwt; + const redacted = redactEventPayload(notice)! as Record; + expect(receiptSchemaValue(redacted)).toBe("paperclip.semantic_tool_receipt.v1"); + expect(redacted.details.find((d: any) => d.name === "operationId").value).toBe(REDACTED_EVENT_VALUE); + expect(redacted.provenance.sessionId).toBe(REDACTED_EVENT_VALUE); + expect(redactEventPayload({ notice, password: "canary", arbitrary: jwt })) + .toMatchObject({ password: REDACTED_EVENT_VALUE, arbitrary: REDACTED_EVENT_VALUE }); + const hostile = receiptNotice(); hostile.details[1].value = jwt; + expect(receiptSchemaValue(redactEventPayload(hostile)!)).toBe(REDACTED_EVENT_VALUE); + const adjacent = receiptNotice(); adjacent.summary = "Authorization: Bearer canary-token"; + expect(JSON.stringify(redactEventPayload(adjacent))).not.toContain("canary-token"); + expect(receiptSchemaValue(redactEventPayload(adjacent)!)).toBe(REDACTED_EVENT_VALUE); + }); + + it("bounds the notice ordinal to the actual producer's 2048-entry limit", () => { + const notice = receiptNotice(); + notice.noticeId = `copilot-evidence-${"a".repeat(24)}-2048`; + expect(redactEventPayload(notice)).toEqual(notice); + for (const ordinal of ["0", "01", "2049", "9999"]) { + notice.noticeId = `copilot-evidence-${"a".repeat(24)}-${ordinal}`; + expect(receiptSchemaValue(redactEventPayload(notice)!)).toBe(REDACTED_EVENT_VALUE); + } + }); + + it("rejects malformed v2 normalized hashes without changing historical v1", () => { + const notice = receiptNotice(2); + notice.details.find((d: any) => d.name === "normalizedInputSha256").value = "bad"; + expect(receiptSchemaValue(redactEventPayload(notice)!)).toBe(REDACTED_EVENT_VALUE); + expect(redactEventPayload(receiptNotice(1))).toEqual(receiptNotice(1)); + }); + it("keeps the discriminator allowlist in exact PRP v1 schema parity", () => { const schema = JSON.parse( readFileSync( diff --git a/server/src/redaction.ts b/server/src/redaction.ts index b56287a611..c6ad3ca96e 100644 --- a/server/src/redaction.ts +++ b/server/src/redaction.ts @@ -878,6 +878,45 @@ function isPaperclipSchemaDiscriminator( ); } +/** Only restore the public literal in this closed receipt-notice context. + * Details use {name,value}, so the ordinary schema-key exemption cannot apply. + * Every other value still passes the generic secret/JWT redactor below. */ +function semanticReceiptSchemaDetail(record: Record): number | null { + if (Object.keys(record).sort().join(",") !== "category,details,noticeId,provenance,recoverable,schema,scope,severity,summary,userActionable" + || record.schema !== "paperclip.provider.notice.v1" || record.scope !== "turn" + || record.severity !== "info" || record.recoverable !== true || record.userActionable !== false + || record.summary !== "Paperclip returned a semantic tool result." + || typeof record.noticeId !== "string" || !/^copilot-evidence-[a-f0-9]{24}-[1-9][0-9]{0,3}$/.test(record.noticeId) + || Number(record.noticeId.slice(record.noticeId.lastIndexOf("-") + 1)) > 2048 + || !isPlainObject(record.provenance) || !Array.isArray(record.details)) return null; + const provenance = record.provenance; + if (Object.keys(provenance).sort().join(",") !== "eventType,method,sessionId,turnId" + || provenance.method !== "paperclip/semantic_tool_result" || provenance.eventType !== "semantic_result" + || ![provenance.sessionId, provenance.turnId].every(value => typeof value === "string" + && value.length > 0 && value.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(value))) return null; + const version = record.category === "paperclip_semantic_tool_receipt_v1" ? 1 + : record.category === "paperclip_semantic_tool_receipt_v2" ? 2 : null; + if (version === null || record.details.length !== (version === 1 ? 7 : 8)) return null; + const details = new Map(); + let schemaIndex: number | null = null; + for (const [index, detail] of record.details.entries()) { + if (!isPlainObject(detail) || Object.keys(detail).sort().join(",") !== "name,value" + || typeof detail.name !== "string" || typeof detail.value !== "string" || details.has(detail.name)) return null; + details.set(detail.name, detail.value); + if (detail.name === "schema") schemaIndex = index; + } + const names = ["stage", "schema", "operationId", "callIdentitySha256", "inputSha256", "resultSha256", "outcome", + ...(version === 2 ? ["normalizedInputSha256"] : [])]; + const hex = (value: string | undefined) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value); + if (!names.every(name => details.has(name)) || details.get("stage") !== "semantic_result" + || details.get("schema") !== `paperclip.semantic_tool_receipt.v${version}` + || !/^[A-Za-z0-9_.:-]{1,256}$/.test(details.get("operationId") ?? "") + || !["callIdentitySha256", "inputSha256", "resultSha256"].every(name => hex(details.get(name))) + || !["returned", "error"].includes(details.get("outcome") ?? "") + || (version === 2 && details.get("normalizedInputSha256") !== "null" && !hex(details.get("normalizedInputSha256")))) return null; + return schemaIndex; +} + export function sanitizeRecord( record: Record, ): Record { @@ -937,6 +976,12 @@ export function sanitizeRecord( } redacted[key] = sanitizeValue(value); } + const schemaIndex = semanticReceiptSchemaDetail(record); + if (schemaIndex !== null && Array.isArray(redacted.details)) { + // Restore only the exact checked discriminator, never sibling data. + (redacted.details[schemaIndex] as Record).value = + (record.details as Array>)[schemaIndex]!.value; + } return redacted; }