From c58a8881f2cb771b35e19e1bf56e08cb89397e83 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 30 Sep 2026 02:54:16 -0500 Subject: [PATCH] fix: validate Cursor plan waits against canonical contract policy Reuse the production completion-contract envelope hash and exercise real contract creation and reuse in accepted-plan persistence tests. Co-Authored-By: Paperclip --- .../heartbeat-process-recovery.test.ts | 18 ++++++++++------- .../native-runtime/completion-contracts.ts | 20 +++++++++++++++---- .../native-cursor-plan-wait.test.ts | 10 ++++++++-- .../native-runtime/native-cursor-plan-wait.ts | 5 +++-- 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 70aa2a42d1..31de9a9f2e 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -1,3 +1,4 @@ +import { ensureNativeCompletionContract } from "../services/native-runtime/completion-contracts.js"; import { readNativeCursorPlanWait, hasCommittedNativeCursorPlanWait } from "../services/native-runtime/native-cursor-plan-wait.js"; import { nativeSha256 } from "../services/native-runtime/canonical.js"; import { buildQuestionResponseDeliveryEnvelope } from "../services/question-response-delivery.js"; @@ -13735,14 +13736,17 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { async function seedAcceptedCursorPlanWait(semanticFinish = false, sourceSequenceOffset = 0) { const f = await seedStrandedIssueFixture({ status: "in_progress", runStatus: "succeeded", livenessState: "advanced" }); - const contractId = randomUUID(), instance = randomUUID(), interactionId = randomUUID(); - const contract = { revision: "1", objective: "Review the plan before further work", criteria: [{ id: "objective", requirement: "Explicit completion required" }] }; + const instance = randomUUID(), interactionId = randomUUID(); + const contractInput = { db, companyId: f.companyId, + issue: { id: f.issueId, title: "Review the plan before further work", description: "Explicit completion required" }, actorId: "test" }; + const { row: persistedContract, contract } = await ensureNativeCompletionContract(contractInput); + const reused = await ensureNativeCompletionContract(contractInput); + expect(reused.row.id).toBe(persistedContract.id); + expect(reused.contract).toEqual(contract); + const contractId = persistedContract.id, contractSha = persistedContract.canonicalSha256; + // Production binds policy/schema as well as the body; a body-only hash is not a valid contract receipt. + expect(contractSha).not.toBe(nativeSha256(contract)); const model = "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]"; - const contractSha = nativeSha256(contract); - await db.insert(completionContracts).values({ id: contractId, companyId: f.companyId, issueId: f.issueId, - revision: 1, schemaVersion: "paperclip.completion-contract.v1", policyVersion: "phase6-v7", risk: "low", - completionAuthority: "agent_claim_policy", incompleteCriteriaPolicy: "preserve_non_terminal", contractJson: contract, - canonicalSha256: contractSha, createdByActorType: "system", createdByActorId: "test" }); await db.update(agents).set({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "cursor", model, acpxSessionMode: "plan", acpxPermissionMode: "approve-all" } }).where(eq(agents.id, f.agentId)); await db.update(agentWakeupRequests).set({ status: "completed" }).where(eq(agentWakeupRequests.id, f.wakeupRequestId)); await db.update(heartbeatRuns).set({ runtimeMode: "native", nativeIssueId: f.issueId, nativeSessionId: f.runId, diff --git a/server/src/services/native-runtime/completion-contracts.ts b/server/src/services/native-runtime/completion-contracts.ts index 3ce22758fc..f8a642769d 100644 --- a/server/src/services/native-runtime/completion-contracts.ts +++ b/server/src/services/native-runtime/completion-contracts.ts @@ -10,6 +10,18 @@ import { nativeSha256 } from "./canonical.js"; export const NATIVE_COMPLETION_CONTRACT_SCHEMA = "paperclip.completion-contract.v1"; export const NATIVE_COMPLETION_POLICY_VERSION = "phase6-v4"; +/** Canonical persisted receipt includes the policy that governs the contract. */ +export function nativeCompletionContractSha256(row: Pick): string { + return nativeSha256({ + schemaVersion: row.schemaVersion, + policyVersion: row.policyVersion, + risk: row.risk, + completionAuthority: row.completionAuthority, + contract: row.contractJson, + }); +} + type CompletionComment = { id?: string; body: string; @@ -162,11 +174,11 @@ export async function ensureNativeCompletionContract(input: { immediateRequests: input.immediateRequests, humanResponseId: input.humanResponseId, }); - const latestCandidateSha256 = nativeSha256({ + const latestCandidateSha256 = nativeCompletionContractSha256({ schemaVersion: NATIVE_COMPLETION_CONTRACT_SCHEMA, policyVersion: NATIVE_COMPLETION_POLICY_VERSION, ...policy, - contract: latestCandidate, + contractJson: latestCandidate as unknown as Record, }); if (latest?.canonicalSha256 === latestCandidateSha256) { return { row: latest, contract: latestCandidate, sources: buildNativeCompletionContractSources(input) }; @@ -179,11 +191,11 @@ export async function ensureNativeCompletionContract(input: { immediateRequests: input.immediateRequests, humanResponseId: input.humanResponseId, }); - const canonicalSha256 = nativeSha256({ + const canonicalSha256 = nativeCompletionContractSha256({ schemaVersion: NATIVE_COMPLETION_CONTRACT_SCHEMA, policyVersion: NATIVE_COMPLETION_POLICY_VERSION, ...policy, - contract, + contractJson: contract as unknown as Record, }); const [row] = await tx.insert(completionContracts).values({ companyId: input.companyId, diff --git a/server/src/services/native-runtime/native-cursor-plan-wait.test.ts b/server/src/services/native-runtime/native-cursor-plan-wait.test.ts index 1bfad5bdca..8fb6e7ef26 100644 --- a/server/src/services/native-runtime/native-cursor-plan-wait.test.ts +++ b/server/src/services/native-runtime/native-cursor-plan-wait.test.ts @@ -2,12 +2,15 @@ import { describe, expect, it, vi } from "vitest"; import * as runner from "../../vendor/paperclip-runner/index.js"; import { resolveQualifiedAcpxProfile, validatePrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js"; import { buildQuestionResponseDeliveryEnvelope } from "../question-response-delivery.js"; +import { NATIVE_COMPLETION_CONTRACT_SCHEMA, NATIVE_COMPLETION_POLICY_VERSION } from "./completion-contracts.js"; import { nativeSha256 } from "./canonical.js"; import { nativeCursorPlanWaitFromFacts, type CursorPlanWaitFacts } from "./native-cursor-plan-wait.js"; function fixture(): CursorPlanWaitFacts { const b = { companyId: "company", issueId: "issue", agentId: "agent", runId: "run" }; const contract = { revision: "revision", criteria: [{ id: "criterion" }] }; + const contractMetadata = { schemaVersion: NATIVE_COMPLETION_CONTRACT_SCHEMA, policyVersion: NATIVE_COMPLETION_POLICY_VERSION, risk: "low", completionAuthority: "agent_claim_policy" }; + const contractSha = nativeSha256({ ...contractMetadata, contract }); const planId = `plan-${"a".repeat(64)}`; const input = { schema: "paperclip.question_set.v1", title: "Plan", description: "Exact revised plan text", questions: [{ id: planId, prompt: "Proceed?", required: true, answerMode: "single_select", options: [{ id: "accept", label: "Accept" }, { id: "reject", label: "Reject" }, { id: "cancel", label: "Cancel" }] }] }; const i = { id: "interaction", ...b, sourceRunId: b.runId, createdByAgentId: b.agentId, resolvedByUserId: "board", resolvedByAgentId: null, resolvedAt: new Date(0), @@ -21,8 +24,8 @@ function fixture(): CursorPlanWaitFacts { }; return { binding: b, - run: { id: b.runId, companyId: b.companyId, agentId: b.agentId, nativeIssueId: b.issueId, runtimeMode: "native", runnerInstanceId: "instance", status: "running", completionContractId: "contract", completionContractSha256: nativeSha256(contract), runnerProfileJson: { nativeExecutionInput: { binding: b, provider: { kind: "acpx", agent: "cursor", cursorMode: "plan", model: "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", profile: resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]") }, session: { normalizedSessionId: "session" }, completionContract: { id: "contract", sha256: nativeSha256(contract), contract } } } }, - contract: { id: "contract", canonicalSha256: nativeSha256(contract), contractJson: contract }, + run: { id: b.runId, companyId: b.companyId, agentId: b.agentId, nativeIssueId: b.issueId, runtimeMode: "native", runnerInstanceId: "instance", status: "running", completionContractId: "contract", completionContractSha256: contractSha, runnerProfileJson: { nativeExecutionInput: { binding: b, provider: { kind: "acpx", agent: "cursor", cursorMode: "plan", model: "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", profile: resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]") }, session: { normalizedSessionId: "session" }, completionContract: { id: "contract", sha256: contractSha, contract } } } }, + contract: { id: "contract", ...contractMetadata, canonicalSha256: contractSha, contractJson: contract }, events: [ event(275, "runtime_request.created", { request: { schema: "paperclip.runtime_request.v2", status: "pending", type: "input", requestKind: "runtime", requestId: "request", turnId: "turn", itemId: "native-plan-tool", origin: { provider: "cursor", method: "cursor/create_plan", adapter: "acpx-runtime-sidecar" }, input } }), event(294, "tool.execution.started", { schema: "paperclip.tool.execution.v1", executionId: "native-plan-tool", transport: "builtin", operation: "execute", status: "running", name: "arbitrary display name" }), @@ -110,6 +113,9 @@ describe("accepted Cursor plan passive-wait authority", () => { ["cancelled run", (f: CursorPlanWaitFacts) => { f.run.status = "cancelled"; }], ["Agent mode", (f: CursorPlanWaitFacts) => { (f.run.runnerProfileJson as any).nativeExecutionInput.provider.cursorMode = "agent"; }], ["stale profile", (f: CursorPlanWaitFacts) => { (f.run.runnerProfileJson as any).nativeExecutionInput.provider.profile = { ...resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]"), commandDigest: "old" }; }], + ["changed contract policy", (f: CursorPlanWaitFacts) => { f.contract.policyVersion = "tampered-policy"; }], + ["changed completion authority", (f: CursorPlanWaitFacts) => { f.contract.completionAuthority = "server_arbiter"; }], + ["changed contract hash", (f: CursorPlanWaitFacts) => { f.contract.canonicalSha256 = "f".repeat(64); }], ["changed contract", (f: CursorPlanWaitFacts) => { f.contract.contractJson.revision = "new"; }], ["unknown origin", (f: CursorPlanWaitFacts) => editEvent(f, 0, e => { e.payload.request.origin.provider = "acpx"; })], ["wrong method", (f: CursorPlanWaitFacts) => editEvent(f, 0, e => { e.payload.request.origin.method = "cursor/ask_question"; })], diff --git a/server/src/services/native-runtime/native-cursor-plan-wait.ts b/server/src/services/native-runtime/native-cursor-plan-wait.ts index 3c58ed4d44..9825fff410 100644 --- a/server/src/services/native-runtime/native-cursor-plan-wait.ts +++ b/server/src/services/native-runtime/native-cursor-plan-wait.ts @@ -10,6 +10,7 @@ import { type PrpStructuredRunResult, } from "../../vendor/paperclip-runner/index.js"; import { buildQuestionResponseDeliveryEnvelope } from "../question-response-delivery.js"; +import { nativeCompletionContractSha256 } from "./completion-contracts.js"; import { nativeSha256 } from "./canonical.js"; type Binding = { companyId: string; issueId: string; runId: string; agentId: string }; @@ -43,7 +44,7 @@ export interface NativeCursorPlanWaitSource extends Binding { export interface CursorPlanWaitFacts { binding: Binding; run: Pick; - contract: Pick; + contract: Pick; events: Array>; interactions: Array<{ interaction: typeof issueThreadInteractions.$inferSelect; delivery: typeof issueQuestionResponseDeliveries.$inferSelect }>; } @@ -71,7 +72,7 @@ function cursorPlanWaitFromFacts(facts: CursorPlanWaitFacts, committedSource?: N } if (run.id !== b.runId || run.companyId !== b.companyId || run.agentId !== b.agentId || run.nativeIssueId !== b.issueId || run.runtimeMode !== "native" || !["running", "succeeded"].includes(run.status) || !Object.entries(b).every(([key, value]) => record(admission.binding)[key] === value) || provider.kind !== "acpx" || provider.agent !== "cursor" || provider.cursorMode !== "plan" || typeof provider.model !== "string" || !provider.model.trim() || - record(admission.completionContract).id !== contract.id || record(admission.completionContract).sha256 !== contract.canonicalSha256 || nativeSha256(contract.contractJson) !== contract.canonicalSha256 || run.completionContractId !== contract.id || run.completionContractSha256 !== contract.canonicalSha256 || !same(contract.contractJson, record(admission.completionContract).contract)) return null; + record(admission.completionContract).id !== contract.id || record(admission.completionContract).sha256 !== contract.canonicalSha256 || nativeCompletionContractSha256(contract) !== contract.canonicalSha256 || run.completionContractId !== contract.id || run.completionContractSha256 !== contract.canonicalSha256 || !same(contract.contractJson, record(admission.completionContract).contract)) return null; const sessionId = record(admission.session).normalizedSessionId; if (typeof sessionId !== "string" || !sessionId || facts.events.length === 0 || facts.events.length > 1000) return null; const events: Array> = [];