From 7fc2da7d34dd5332234df4f607703ccd4f0ecda0 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 16:49:15 -0500 Subject: [PATCH] Preserve Cursor command and denial evidence in both ACP transports Observe only the active native prompt and bind permission delivery to the provider response write. Keep evidence failures separate from permission and terminal authority. Co-Authored-By: Paperclip --- .../src/cli/acpx-runtime-sidecar.test.ts | 29 +++++++++++++ .../src/cli/acpx-runtime-sidecar.ts | 13 +++--- .../drivers/acpx/codex-acpx-driver.test.ts | 42 +++++++++++++++++++ .../src/drivers/acpx/codex-acpx-driver.ts | 13 +++--- 4 files changed, 87 insertions(+), 10 deletions(-) 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 be172d4ae2..3179baf3ce 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -11,6 +11,9 @@ import { resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles. import { ACPX_SIDECAR_PROTOCOL_VERSION } from "../drivers/acpx/sidecar-protocol.js"; import { canonicalProviderEventsFromAcpxRuntimeEvent } from "../provider-events.js"; import { createPiMessageProjection } from "../drivers/acpx/pi-message-projection.js"; +import { createCopilotToolEvidence } from "../drivers/acpx/copilot-tool-evidence.js"; +import { createCursorToolEvidence } from "../drivers/acpx/cursor-tool-evidence.js"; +import { validateAcpxRichEvent } from "../drivers/acpx/profile-extensions.js"; import { awaitSidecarCleanupWithin, closeActiveSidecarHostWithin, @@ -36,6 +39,32 @@ afterEach(async () => { }); describe("qualified ACPX runtime sidecar", () => { + it.each(["cursor", "copilot", "pi"])("binds native tool evidence to the active sidecar turn for %s", agent => { + const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); + const start = source.indexOf(" const evidenceFactory ="); + const end = source.indexOf(" let usageBefore:", start); + expect(start).toBeGreaterThan(0); + const emitted: unknown[] = []; + const create = new Function("createCopilotToolEvidence", "createCursorToolEvidence", "validateAcpxRichEvent", "emit", "agent", ` + const activeHost = { identity: () => ({ backendSessionId: "session" }) }; + let host = activeHost, turnId = "turn"; + const currentTurnId = "turn", openParams = { agent, workingDirectory: "/workspace" }; + const diagnostic = () => {}; + ${source.slice(start, end).replaceAll("openParams!", "openParams")} + return { evidence: toolEvidence, retire: () => { turnId = null; } }; + `)(createCopilotToolEvidence, createCursorToolEvidence, validateAcpxRichEvent, (...args: unknown[]) => emitted.push(args), agent); + const tool = { type: "tool_call", tag: "tool_call", toolCallId: "tool", kind: "execute", status: "pending", rawInput: { command: "printf private-value" } }; + create.evidence?.tool(tool); + expect(emitted).toHaveLength(agent === "pi" ? 0 : 1); + if (agent !== "pi") expect(emitted[0]).toEqual(["runtime.rich_event", expect.objectContaining({ payload: expect.objectContaining({ + category: `${agent}_tool_evidence_v1`, provenance: expect.objectContaining({ sessionId: "session", turnId: "turn" }), + }) }), "turn"]); + expect(JSON.stringify(emitted)).not.toContain("private-value"); + create.retire(); + create.evidence?.tool({ ...tool, tag: "tool_call_update", status: "failed" }); + expect(emitted).toHaveLength(agent === "pi" ? 0 : 1); + }); + 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")); diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 1921a1e107..f6a3db693c 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -63,6 +63,7 @@ import { } from "../drivers/acpx/sidecar-protocol.js"; import { safeAcpxLocations } from "./acpx-sidecar-locations.js"; import { createCopilotToolEvidence, type CopilotToolEvidence } from "../drivers/acpx/copilot-tool-evidence.js"; +import { createCursorToolEvidence, type CursorToolEvidence } from "../drivers/acpx/cursor-tool-evidence.js"; import { persistedAcpxTurnUsage, acpxUsageEstimateNotice, @@ -370,13 +371,15 @@ async function dispatch( waitForInput: (input, context) => waitForExtensionInput(currentTurnId, input, context), emit: event => emit("runtime.rich_event", { ...event }, currentTurnId), }); - const toolEvidence = openParams!.agent === "copilot" ? createCopilotToolEvidence({ + const evidenceFactory = openParams!.agent === "copilot" ? createCopilotToolEvidence + : openParams!.agent === "cursor" ? createCursorToolEvidence : undefined; + const toolEvidence = evidenceFactory?.({ sessionId: activeHost.identity().backendSessionId, turnId: currentTurnId, workingDirectory: openParams!.workingDirectory, active: () => turnId === currentTurnId && host === activeHost, emit: event => { validateAcpxRichEvent(event); emit("runtime.rich_event", { ...event }, currentTurnId); }, - unavailable: () => diagnostic("copilot_evidence_unavailable", "Copilot tool evidence is incomplete; permission and terminal outcomes are unchanged."), - }) : undefined; + unavailable: () => diagnostic(`${openParams!.agent}_evidence_unavailable`, "ACP tool evidence is incomplete; permission and terminal outcomes are unchanged."), + }); let usageBefore: unknown; try { usageBefore = await readSidecarHostStatusWithin(activeHost); @@ -602,7 +605,7 @@ async function pumpTurn( activeHost: AcpxRuntimeHost, usageBefore: unknown, drainExtensions: () => Promise, - toolEvidence?: CopilotToolEvidence, + toolEvidence?: CopilotToolEvidence | CursorToolEvidence, ): Promise { let terminal: Record; try { @@ -757,7 +760,7 @@ async function waitForPermission( activeTurnId: string, request: AcpPermissionRequest, context: { signal: AbortSignal; responseDelivery?: Promise }, - toolEvidence?: CopilotToolEvidence, + toolEvidence?: CopilotToolEvidence | CursorToolEvidence, ): Promise { const { signal } = context; if (turnId !== activeTurnId || signal.aborted || permissions.size >= MAX_PENDING_INPUTS) { 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 192b2666f5..e554978f4d 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 @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; import { describe, expect, it, vi } from "vitest"; import type { AcpRuntimeEvent } from "acpx/runtime"; @@ -1606,6 +1607,47 @@ describe("Codex ACPX harness driver", () => { await session.close({ reason: "receipt checked" }); }); + it.each(["written", "failed"] as const)("binds Cursor denial evidence to its original tool and response write: %s", async outcome => { + const command = "printf 'sensitive-value' > /workspace/denied.txt"; + const fixture = driverFixture({ agent: "cursor", model: "explicit-test-model", providerPolicy: { readOnly: false } }, { + runtimeEvents: [{ type: "tool_call", tag: "tool_call", toolCallId: "cursor-tool", title: "Run command", kind: "execute", status: "pending", rawInput: { command } }], + }); + const session = await fixture.driver.openSession({ runId: "run-cursor-receipt", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); + const origin = collectUntil(session.events(), "provider.notice.recorded"); + const { turnId } = await session.startTurn({ message: { text: "Request the command." } }); + const originEvents = await origin; + const created = collectUntil(session.events(), "runtime_request.created"); + const callback = fixture.host.startTurn.mock.calls[0]![0].onPermissionRequest!; + const receipt = deferred(); + const providerResponse = callback({ inferredKind: "execute", raw: { + sessionId: "backend-1", toolCall: { toolCallId: "cursor-tool", title: "Run command", kind: "execute" }, + options: [{ optionId: "deny", kind: "reject_once", name: "Deny" }], + } } as Parameters[0], { signal: new AbortController().signal, responseDelivery: receipt.promise }); + const requested = await created; + const request = session.pendingRuntimeRequests!()[0]!; + const evidence = (events: PrpEvent[]) => events.filter(event => event.eventType === "provider.notice.recorded" && event.payload.category === "cursor_tool_evidence_v1"); + const fields = (event: PrpEvent) => Object.fromEntries((event.payload.details as Array<{ name: string; value: string }>).map(field => [field.name, field.value])); + const commandSha256 = `sha256:${createHash("sha256").update(command).digest("hex")}`; + expect(evidence(requested).map(fields)).toEqual([expect.objectContaining({ stage: "permission_requested", toolCallId: "cursor-tool", requestId: request.requestId, commandSha256, declineOffered: "true" })]); + const settled = collectUntil(session.events(), outcome === "written" ? "runtime_request.resolved" : "runtime_request.expired"); + let acknowledged = false; + const resolution = session.resolveRuntimeRequest!({ requestId: request.requestId, turnId, resolution: { action: "decline" } }).then(() => { acknowledged = true; }); + await expect(providerResponse).resolves.toEqual({ outcome: "reject_once" }); + expect(acknowledged).toBe(false); + if (outcome === "written") { receipt.resolve(); await resolution; } + else { + const failure = expect(resolution).rejects.toThrow("pipe failed"); + receipt.reject(new Error("pipe failed")); await failure; + } + const terminalEvents = await settled; + expect(evidence(terminalEvents).map(fields)).toEqual(outcome === "written" + ? [expect.objectContaining({ stage: "permission_delivered", outcome: "reject_once", commandSha256, requestId: request.requestId })] : []); + expect(JSON.stringify([...evidence(originEvents), ...evidence(requested), ...evidence(terminalEvents)])).not.toContain("sensitive-value"); + expect(session.pendingRuntimeRequests!()).toHaveLength(0); + if (outcome === "written") fixture.finishTurn({ status: "completed", stopReason: "end_turn" }); + await session.close({ reason: "Cursor receipt verified" }); + }); + it.each(["copilot", "codex"] as const)("preserves pinned attached-shell evidence only on the Copilot direct driver: %s", async agent => { const wire = JSON.parse(readFileSync(new URL("./fixtures/copilot-tool-evidence.json", import.meta.url), "utf8"))["attached-shell"]; const runtimeEvents = wire.filter((frame: any) => frame.method === "session/update").map((frame: any) => ({ ...frame.params.update, type: "tool_call", tag: frame.params.update.sessionUpdate })) as AcpRuntimeEvent[]; 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 ca0b296708..ccbe6abd90 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -1,4 +1,5 @@ import { createCopilotToolEvidence, type CopilotToolEvidence } from "./copilot-tool-evidence.js"; +import { createCursorToolEvidence, type CursorToolEvidence } from "./cursor-tool-evidence.js"; import { requireAcpxResponseDelivery } from "./response-delivery.js"; import { createPiMessageProjection, piBoundaryClearsFinal, type PiProjectedMessageEvent } from "./pi-message-projection.js"; import { acpxProfileClientCapabilities, bindAcpxExtensionTurn, validateAcpxRichEvent, createAcpxProfileExtensionAdapter, type AcpxExtensionInput } from "./profile-extensions.js"; @@ -901,14 +902,16 @@ class CodexAcpxSession implements HarnessSession { } }, }); - const toolEvidence = this.#agent === "copilot" ? createCopilotToolEvidence({ + const evidenceFactory = this.#agent === "copilot" ? createCopilotToolEvidence + : this.#agent === "cursor" ? createCursorToolEvidence : undefined; + const toolEvidence = evidenceFactory?.({ sessionId: this.#host.identity().backendSessionId, turnId, workingDirectory: this.#input.workingDirectory, active: () => this.#activeTurnId === turnId && !this.#closingStarted, emit: event => { validateAcpxRichEvent(event); - if (!this.#emit(event.eventType, event.payload, { turnId, itemId: event.itemId })) throw new Error("Copilot activity could not be retained"); + if (!this.#emit(event.eventType, event.payload, { turnId, itemId: event.itemId })) throw new Error("ACP tool activity could not be retained"); }, - }) : undefined; + }); let turn: AcpxRuntimeTurn; const usageBefore = await readUsageStatus(this.#host); try { @@ -1438,7 +1441,7 @@ class CodexAcpxSession implements HarnessSession { .catch(() => undefined); } - async #pumpTurn(turnId: string, turn: AcpxRuntimeTurn, drainExtensions: () => Promise, usageBefore: unknown, toolEvidence?: CopilotToolEvidence): Promise { + async #pumpTurn(turnId: string, turn: AcpxRuntimeTurn, drainExtensions: () => Promise, usageBefore: unknown, toolEvidence?: CopilotToolEvidence | CursorToolEvidence): Promise { try { let index = 0; const normalizeToolEvent = @@ -1728,7 +1731,7 @@ class CodexAcpxSession implements HarnessSession { turnId: string, request: AcpPermissionRequest, context: { signal: AbortSignal; responseDelivery?: Promise }, - toolEvidence?: CopilotToolEvidence, + toolEvidence?: CopilotToolEvidence | CursorToolEvidence, ): Promise { const { signal } = context; if (this.#closed || this.#activeTurnId !== turnId || signal.aborted