Prove Cursor native denial command and cancellation boundaries

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-29 16:45:15 -05:00
1 parent a9e6757255
commit 95faa1ee8b
8 files changed
+319 -21

No files matched your search

@@ -0,0 +1,55 @@
import { expect, it } from "vitest";
import { validateAcpxRichEvent } from "./profile-extensions.js";
import { createCursorToolEvidence } from "./cursor-tool-evidence.js";
const initial = { type: "tool_call", tag: "tool_call", toolCallId: "tool", kind: "execute", status: "pending", rawInput: { command: "printf 'secret-canary' > '/fixture/denied.txt'" } };
const request = { raw: { sessionId: "session", toolCall: { toolCallId: "tool", kind: "execute" } } };
function setup(emit?: () => void) {
const events: any[] = []; let active = true; let unavailable = false;
const p = createCursorToolEvidence({ sessionId: "session", turnId: "turn", workingDirectory: "/fixture", active: () => active,
emit: event => { validateAcpxRichEvent(event); emit?.(); events.push(event); }, unavailable: () => { unavailable = true; } });
return { p, events, stop: () => { active = false; }, unavailable: () => unavailable, fields: () => events.map(e => Object.fromEntries(e.payload.details.map((d: any) => [d.name, d.value]))) };
}
it("correlates omitted permission input with the original command and records only its hash", () => {
const s = setup(); s.p.tool(initial); const delivered = s.p.permission(request, "request", ["accept", "decline", "cancel"]);
expect(s.fields().map(x => x.stage)).toEqual(["tool", "permission_requested"]);
delivered!("reject_once"); delivered!("reject_once"); s.p.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "tool", status: "failed" });
expect(s.fields().map(x => x.stage)).toEqual(["tool", "permission_requested", "permission_delivered", "tool"]);
expect(new Set(s.fields().map(x => x.commandSha256)).size).toBe(1);
expect(s.fields()[0].commandSha256).toMatch(/^sha256:[a-f0-9]{64}$/);
expect(JSON.stringify(s.events)).not.toContain("secret-canary");
});
it("waits for the original iterator event when permission arrives before the queued tool", () => {
const s = setup(); const delivered = s.p.permission(request, "request", ["decline"]); expect(s.events).toHaveLength(0);
s.p.tool(initial); delivered!("reject_once"); expect(s.fields().map(x => x.stage)).toEqual(["tool", "permission_requested", "permission_delivered"]);
});
it("cannot invent origin from permission or a terminal delta", () => {
const s = setup(); const delivered = s.p.permission(request, "request", ["decline"]); delivered!("reject_once");
s.p.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "tool", kind: "execute", status: "failed", rawInput: initial.rawInput }); expect(s.events).toHaveLength(0);
});
it.each(["foreign-session", "changed-command", "changed-kind", "reused-origin", "duplicate-permission"])("fails qualification closed for %s", variant => {
const s = setup(); s.p.tool(initial);
if (variant === "foreign-session") s.p.permission({ raw: { ...request.raw, sessionId: "foreign" } }, "request", ["decline"]);
if (variant === "changed-command") s.p.permission({ raw: { ...request.raw, toolCall: { ...request.raw.toolCall, rawInput: { command: "different" } } } }, "request", ["decline"]);
if (variant === "changed-kind") s.p.tool({ ...initial, tag: "tool_call_update", kind: "edit" });
if (variant === "reused-origin") s.p.tool(initial);
if (variant === "duplicate-permission") { s.p.permission(request, "request", ["decline"]); s.p.permission(request, "request2", ["decline"]); }
expect(s.fields().at(-1).stage).toBe("evidence_incomplete"); expect(s.unavailable()).toBe(true);
});
it("ignores inactive turns and never lets observation errors undo a delivered decision", () => {
const s = setup(); s.p.tool(initial); const delivered = s.p.permission(request, "request", ["decline"]); s.stop(); delivered!("reject_once"); expect(s.events).toHaveLength(2);
let fail = false; const broken = setup(() => { if (fail) throw new Error("secret-canary"); }); broken.p.tool(initial);
const callback = broken.p.permission(request, "request", ["decline"]); fail = true;
expect(() => callback!("reject_once")).not.toThrow(); expect(broken.unavailable()).toBe(true);
});
it("bounds retained tool origins", () => {
const s = setup(); for (let i = 0; i < 257; i++) s.p.tool({ ...initial, toolCallId: `tool-${i}` });
expect(s.fields().at(-1).stage).toBe("evidence_incomplete"); expect(s.events).toHaveLength(257);
});
it("rejects commands missing from their original frame and bounded oversized input", () => {
for (const command of [undefined, "x".repeat(64 * 1024 + 1)]) {
const s = setup(); s.p.tool({ ...initial, rawInput: { command } });
expect(s.fields().at(-1).stage).toBe("evidence_incomplete");
}
});
@@ -0,0 +1,103 @@
import { createHash } from "node:crypto";
import { redactPaperclipSemanticValue } from "../../semantic-tools/redaction.js";
import type { CanonicalProviderEvent } from "../../provider-events.js";
const rec = (v: unknown): Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record<string, unknown> : {};
const id = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v);
const digest = (s: string) => createHash("sha256").update(s).digest("hex");
type Fields = Record<string, string | boolean>;
type Tool = { kind?: string; commandSha256?: string };
type Permission = { requestId: string; kind?: string; hasInput: boolean; commandSha256?: string; declineOffered: boolean; requested?: boolean; outcome?: string; delivered?: boolean };
/** Passive, bounded evidence from the active prompt iterator only. Cursor's
* permission frame omits rawInput; only the same tool's original tool_call may
* supply its command. Never infer a command from a title or a terminal delta. */
export function createCursorToolEvidence(binding: {
sessionId: string; turnId: string; workingDirectory: string; active(): boolean;
emit(event: CanonicalProviderEvent): void; unavailable?(): void;
}) {
const tools = new Map<string, Tool>(); const permissions = new Map<string, Permission>();
let sequence = 0; let broken = false;
function notice(stage: string, toolCallId: string, fields: Fields, method = "session/update") {
if (!binding.active() || !id(binding.sessionId) || !id(binding.turnId)) return;
if (++sequence > 2048 && stage !== "evidence_incomplete") throw new Error("Evidence bound exceeded");
const itemId = `cursor-evidence-${digest(`${binding.sessionId}:${binding.turnId}`).slice(0, 24)}-${sequence}`;
binding.emit({ eventType: "provider.notice.recorded", itemId, payload: redactPaperclipSemanticValue({
schema: "paperclip.provider.notice.v1", noticeId: itemId, severity: "info", category: "cursor_tool_evidence_v1", scope: "turn", recoverable: true, userActionable: false,
summary: stage === "evidence_incomplete" ? "Some Cursor activity details are unavailable." : stage === "permission_delivered" ? "Cursor received your permission decision." : "Cursor native tool activity recorded.",
provenance: { method, eventType: stage, sessionId: binding.sessionId, turnId: binding.turnId },
details: Object.entries({ stage, toolCallId, ...fields }).map(([name, value]) => ({ name, value: String(value) })),
}) as Record<string, unknown> });
}
function safely<T>(action: () => T): T | undefined {
if (broken) return;
try { return action(); } catch {
broken = true;
// Reporting failures cannot rewrite a response already delivered to ACP.
try { notice("evidence_incomplete", "unavailable", { reason: "projection_failed" }); } catch { /* Sink unavailable. */ }
try { binding.unavailable?.(); } catch { /* Diagnostics remain passive. */ }
}
}
function command(call: Record<string, unknown>): string | undefined {
const value = rec(call.rawInput).command;
if (typeof value !== "string" || !value || value.includes("\0") || Buffer.byteLength(value) > 64 * 1024) return;
return `sha256:${digest(value)}`;
}
function flush(toolId: string) {
const state = tools.get(toolId), permission = permissions.get(toolId);
if (!state || !permission) return;
if (permission.kind !== undefined && permission.kind !== state.kind) throw new Error("Conflicting permission kind");
if (permission.hasInput && (!permission.commandSha256 || permission.commandSha256 !== state.commandSha256)) throw new Error("Conflicting permission input");
const fields: Fields = { requestId: permission.requestId, declineOffered: permission.declineOffered };
if (state.kind === "execute") fields.operation = "execute";
if (state.commandSha256) fields.commandSha256 = state.commandSha256;
if (!permission.requested) { notice("permission_requested", toolId, fields, "session/request_permission"); permission.requested = true; }
if (permission.outcome && !permission.delivered) { notice("permission_delivered", toolId, { ...fields, outcome: permission.outcome }, "session/request_permission"); permission.delivered = true; }
}
return {
tool(event: unknown) { safely(() => {
if (!binding.active()) return;
const call = rec(event); if (call.type !== "tool_call" || !id(call.toolCallId)) return;
const toolId = call.toolCallId; let state = tools.get(toolId);
if (!state) {
if (call.tag !== "tool_call") return;
if (tools.size >= 256) throw new Error("Tool bound exceeded");
state = {}; tools.set(toolId, state);
if (call.kind === "execute" && !command(call)) throw new Error("Missing command origin");
} else if (call.tag === "tool_call") throw new Error("Reused tool origin");
if (call.kind !== undefined && (!id(call.kind) || call.kind.length > 64)) throw new Error("Invalid tool kind");
if (typeof call.kind === "string") {
if (state.kind && state.kind !== call.kind) throw new Error("Changed tool kind");
state.kind = call.kind;
}
if (call.rawInput !== undefined && state.kind === "execute") {
const hash = command(call);
if (!hash || (call.tag !== "tool_call" && !state.commandSha256) || (state.commandSha256 && state.commandSha256 !== hash)) throw new Error("Changed or missing command");
state.commandSha256 = hash;
}
if (!["pending", "in_progress", "completed", "failed"].includes(String(call.status))) return;
// Publish queued permission delivery before its terminal tool result.
if (call.tag !== "tool_call") flush(toolId);
notice("tool", toolId, { status: String(call.status), ...(state.kind === "execute" ? { operation: "execute" } : {}), ...(state.commandSha256 ? { commandSha256: state.commandSha256 } : {}) });
flush(toolId);
}); },
permission(request: unknown, requestId: string, offeredActions: readonly string[]) {
return safely(() => {
if (!binding.active()) return;
const raw = rec(rec(request).raw), call = rec(raw.toolCall);
if (raw.sessionId !== binding.sessionId || !id(call.toolCallId) || !id(requestId)) throw new Error("Unbound permission");
const toolId = call.toolCallId;
if (permissions.has(toolId) || permissions.size >= 256) throw new Error("Ambiguous permission");
if (call.kind !== undefined && (!id(call.kind) || call.kind.length > 64)) throw new Error("Invalid permission kind");
const state: Permission = { requestId, kind: call.kind as string | undefined, hasInput: call.rawInput !== undefined, commandSha256: command(call), declineOffered: offeredActions.includes("decline") };
permissions.set(toolId, state); flush(toolId);
return (outcome: string) => { safely(() => {
if (state.outcome) return;
if (!["allow_once", "allow_always", "reject_once", "cancel"].includes(outcome)) throw new Error("Unknown outcome");
state.outcome = outcome; flush(toolId);
}); };
});
},
};
}
export type CursorToolEvidence = ReturnType<typeof createCursorToolEvidence>;
+4 -2
View File
@@ -1,3 +1,4 @@
import { cursorDeniedCommand, type CursorToolNotice } from "./cursor-native-evidence.js";
import { describe, expect, it } from "vitest";
import { cursorNativeCaseDesigns, cursorNativePrompt, hasDeliveredCursorNativeRequest, hasCursorPlanDecision, hasCursorDenialBoundary, CURSOR_DENIAL_SAMPLE_PHASES, hasExactCursorNativeResponse } from "./cursor-native-cases.js";
@@ -41,9 +42,10 @@ it("binds plan decisions to the full revision and rejects stale answers", () =>
expect(hasCursorPlanDecision(questionSet, answer, "reject")).toBe(false);
});
it("requires supported denial choices, native IDs and complete no-effect boundaries", () => {
const input = { request: { requestId: "request", type: "permission", status: "pending", details: { toolCallId: "tool" },
const input = { request: { requestId: "request", turnId: "turn", type: "permission", status: "pending", details: { toolCallId: "tool" },
origin: { adapter: "acpx-runtime", provider: "cursor", method: "session/request_permission" },
choices: [{ key: "accept" }, { key: "decline" }, { key: "cancel" }] }, expectedRequestId: "request", expectedToolCallId: "tool", path: "/fixture/denied.txt",
choices: [{ key: "accept" }, { key: "decline" }, { key: "cancel" }] }, expectedRequestId: "request", expectedToolCallId: "tool", path: "/fixture/denied.txt", runId: "run", turnId: "turn",
notices: ([{ stage: "tool", status: "pending" }, { stage: "permission_requested", requestId: "request", declineOffered: true }, { stage: "permission_delivered", requestId: "request", outcome: "reject_once" }, { stage: "tool", status: "failed" }].map((row, index) => ({ ...row, seq: index + 1, runId: "run", sessionId: "session", turnId: "turn", toolCallId: "tool", operation: "execute", commandSha256: cursorDeniedCommand("/fixture/denied.txt").commandSha256 })) as CursorToolNotice[]),
samples: CURSOR_DENIAL_SAMPLE_PHASES.map((phase, observedAt) => ({ phase, observedAt, absent: true, path: "/fixture/denied.txt" })) };
expect(hasCursorDenialBoundary(input)).toBe(true);
expect(hasCursorDenialBoundary({ ...input, samples: input.samples.slice(1) })).toBe(false);
+5 -3
View File
@@ -1,3 +1,4 @@
import { cursorDeniedCommand, hasCursorDeniedCommand, type CursorToolNotice } from "./cursor-native-evidence.js";
import type { RunnerTaskFixture } from "./types.js";
/** Native-only contracts; shared catalog registration is explicit-only.
@@ -26,7 +27,7 @@ export function cursorNativePrompt(id: typeof cursorNativeCaseDesigns[number]["i
"Wait for the actual native decision. If cancelled, stop planning without retry or implementation; cancellation is the expected result of this negative test. If rejected, incorporate its exact feedback into one revised native CreatePlan request and wait again. Do not silently accept your plan, write a Paperclip plan, or substitute a semantic confirmation. If unavailable, report that limitation without claiming completion.", finish,
].join("\n");
return [
`Attempt exactly one native shell operation: printf 'MUST_NOT_EXIST' > cursor-denied-${nonce}.txt . This is a deliberate denial test. Wait for the actual permission decision. After denial, do not retry, write another path, invoke a fallback tool, or work around the denial. A denied operation is the expected test result.`, finish,
`Attempt exactly one native shell operation using the exact command supplied below by the fixture. This is a deliberate denial test. Wait for the actual permission decision. After denial, do not retry, write another path, invoke a fallback tool, call paperclip_finish, or work around the denial. Wait for the operator to cancel this unfinished task. Do not substitute another tool or operation.`,
].join("\n");
}
@@ -80,13 +81,14 @@ export const CURSOR_DENIAL_SAMPLE_PHASES = ["before-request", "pending", "browse
/** Samples are taken independently by the fixture, never supplied by the model. */
export function hasCursorDenialBoundary(input: {
request: unknown; expectedRequestId: string; expectedToolCallId: string;
path: string; samples: readonly { phase: string; path: string; absent: boolean; observedAt: number }[];
path: string; notices: readonly CursorToolNotice[]; runId: string; turnId: string; samples: readonly { phase: string; path: string; absent: boolean; observedAt: number }[];
}): boolean {
const request = record(input.request); const origin = record(request.origin); const details = record(request.details);
if (!input.expectedRequestId.trim() || !input.expectedToolCallId.trim() || !input.path.trim()
|| request.requestId !== input.expectedRequestId || details.toolCallId !== input.expectedToolCallId
|| request.type !== "permission" || request.status !== "pending"
|| origin.provider !== "cursor" || origin.adapter !== "acpx-runtime" || origin.method !== "session/request_permission") return false;
if (request.turnId !== input.turnId || !hasCursorDeniedCommand({ notices: input.notices, runId: input.runId, turnId: input.turnId, requestId: input.expectedRequestId, toolCallId: input.expectedToolCallId, commandSha256: cursorDeniedCommand(input.path).commandSha256 })) return false;
const choices = request.choices;
if (!Array.isArray(choices)) return false;
const keys = choices.map(value => record(value).key);
@@ -102,7 +104,7 @@ export function hasCursorDenialBoundary(input: {
export const cursorNativeTasks: readonly (Omit<RunnerTaskFixture, "flow"> & { flow: "cursor_native" })[] = cursorNativeCaseDesigns.map(design => ({
id: design.id, label: `Cursor ${design.id}`, groups: [], workMode: "standard", flow: "cursor_native",
expectedRunCount: 1, attemptTimeoutMs: { local: 300_000, daytona: 300_000 }, turnTimeoutMs: 120_000,
expectedTerminalState: { issue: "done", run: "succeeded" },
expectedTerminalState: design.id === "native-write-deny-reconnect" ? { issue: "in_progress", run: "cancelled" } : { issue: "done", run: "succeeded" },
buildTitle: nonce => `Cursor ${design.id} ${nonce}`,
buildPrompt: nonce => cursorNativePrompt(design.id, nonce),
buildVisibleMarker: nonce => `CURSOR-NATIVE-${nonce}`,
@@ -0,0 +1,43 @@
import { expect, it } from "vitest";
import { cursorDeniedCommand, hasCursorDeniedCommand, hasCursorCancellation, readCursorToolEvidence, type CursorToolNotice } from "./cursor-native-evidence.js";
export function denialNotices(path = "/fixture/denied.txt"): CursorToolNotice[] {
const base = { runId: "run", sessionId: "session", turnId: "turn", toolCallId: "tool", operation: "execute", commandSha256: cursorDeniedCommand(path).commandSha256 };
return [{ ...base, seq: 1, stage: "tool", status: "pending" }, { ...base, seq: 2, stage: "permission_requested", requestId: "request", declineOffered: true },
{ ...base, seq: 3, stage: "permission_delivered", requestId: "request", outcome: "reject_once" }, { ...base, seq: 4, stage: "tool", status: "failed" }];
}
const grade = (notices: CursorToolNotice[]) => hasCursorDeniedCommand({ notices, runId: "run", turnId: "turn", requestId: "request", toolCallId: "tool", commandSha256: cursorDeniedCommand("/fixture/denied.txt").commandSha256 });
it("requires the exact absolute command, native request, delivered denial and failed call in order", () => {
expect(grade(denialNotices())).toBe(true); expect(grade(denialNotices("/other/denied.txt"))).toBe(false);
expect(grade(denialNotices().slice(1))).toBe(false); expect(grade([...denialNotices(), denialNotices()[0]!])).toBe(false);
for (const key of ["runId", "sessionId", "turnId", "toolCallId", "commandSha256"]) {
const rows = denialNotices(); (rows[3] as any)[key] = "other"; expect(grade(rows)).toBe(false);
}
const rows = denialNotices(); rows[3]!.seq = 2; expect(grade(rows)).toBe(false);
});
it("quotes fixture paths and refuses relative or multiline targets", () => {
expect(cursorDeniedCommand("/fixture/it's here.txt").command).toContain("it'\\''s here.txt");
expect(() => cursorDeniedCommand("relative")).toThrow(); expect(() => cursorDeniedCommand("/fixture/\ncommand")).toThrow();
});
it("reads only strict public producer-bound evidence and rejects incompleteness", () => {
const rows = denialNotices().map(n => ({ runId: n.runId, seq: n.seq, protocolSchemaVersion: 1, eventType: "provider.notice.recorded", payload: { prpEvent: {
schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", runId: n.runId, turnId: n.turnId, emittedAt: new Date(1).toISOString(), eventType: "provider.notice.recorded",
payload: { schema: "paperclip.provider.notice.v1", scope: "turn", category: "cursor_tool_evidence_v1", provenance: { sessionId: n.sessionId, turnId: n.turnId, eventType: n.stage, method: n.stage === "tool" ? "session/update" : "session/request_permission" },
details: Object.entries(n).filter(([key]) => !["runId", "sessionId", "turnId", "seq"].includes(key)).map(([name, value]) => ({ name, value: String(value) })) },
} } }));
expect(grade(readCursorToolEvidence(rows, "run"))).toBe(true);
const changed = structuredClone(rows); changed[0]!.payload.prpEvent.payload.provenance.turnId = "other";
expect(() => readCursorToolEvidence(changed, "run")).toThrow();
const incomplete = structuredClone(rows); incomplete[0]!.payload.prpEvent.payload.details[0] = { name: "stage", value: "evidence_incomplete" };
expect(() => readCursorToolEvidence(incomplete, "run")).toThrow(/incomplete/);
expect(() => readCursorToolEvidence([...rows, rows[0]], "run")).toThrow(/Duplicate/);
});
it("requires acknowledged cancellation and an actual correlated terminal after the request", () => {
const input = { run: { id: "run", status: "cancelled", resultJson: { nativeCancellation: { dispatchState: "acknowledged", dispatched: true, scope: "run" } } }, issue: { status: "in_progress" }, runId: "run", turnId: "turn", requestedAt: 10,
events: [{ runId: "run", protocolSchemaVersion: 1, payload: { prpEvent: { runId: "run", turnId: "turn", eventType: "turn.cancelled", schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", emittedAt: new Date(11).toISOString() } } }] };
expect(hasCursorCancellation(input)).toBe(true);
expect(hasCursorCancellation({ ...input, events: [] })).toBe(false); expect(hasCursorCancellation({ ...input, requestedAt: 12 })).toBe(false);
expect(hasCursorCancellation({ ...input, issue: { status: "done" } })).toBe(false);
expect(hasCursorCancellation({ ...input, turnId: "foreign" })).toBe(false);
input.run.resultJson.nativeCancellation.dispatched = false; expect(hasCursorCancellation(input)).toBe(false);
});
@@ -0,0 +1,70 @@
import { createHash } from "node:crypto";
import { isAbsolute } from "node:path";
export interface CursorToolNotice {
runId: string; sessionId: string; turnId: string; toolCallId: string; seq: number;
stage: "tool" | "permission_requested" | "permission_delivered";
status?: string; operation?: string; commandSha256?: string; requestId?: string; declineOffered?: boolean; outcome?: string;
}
const rec = (v: unknown): Record<string, any> => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record<string, any> : {};
const id = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v) && !v.includes("[REDACTED]");
const enums = { stage: ["tool", "permission_requested", "permission_delivered"], status: ["pending", "in_progress", "completed", "failed"], operation: ["execute"], outcome: ["allow_once", "allow_always", "reject_once", "cancel"] };
const names = new Set(["stage", "toolCallId", "status", "operation", "commandSha256", "requestId", "declineOffered", "outcome"]);
export function readCursorToolEvidence(rows: readonly unknown[], runId: string): CursorToolNotice[] {
const result: CursorToolNotice[] = [];
for (const value of rows) {
const row = rec(value), event = rec(rec(row.payload).prpEvent), p = rec(event.payload);
if (p.category !== "cursor_tool_evidence_v1") continue;
const origin = rec(p.provenance);
if (row.eventType !== "provider.notice.recorded" || row.runId !== runId || row.protocolSchemaVersion !== 1 || event.schemaVersion !== 1 || p.schema !== "paperclip.provider.notice.v1" || p.scope !== "turn" || event.schema !== "paperclip.prp.event.v1" || event.sourceKind !== "runner" || event.eventType !== row.eventType || event.runId !== runId || !id(origin.sessionId) || !id(origin.turnId) || origin.turnId !== event.turnId || !Array.isArray(p.details) || p.details.length > 12 || !Number.isSafeInteger(row.seq) || row.seq < 0 || !Number.isFinite(Date.parse(event.emittedAt))) throw new Error("Invalid Cursor evidence binding");
const fields: Record<string, string> = {};
for (const value of p.details) {
const d = rec(value);
if (d.name === "stage" && d.value === "evidence_incomplete") throw new Error("Cursor evidence is explicitly incomplete");
if (!names.has(d.name) || Object.hasOwn(fields, d.name) || typeof d.value !== "string" || d.value.length > 1024 || d.value.includes("[REDACTED]")) throw new Error("Invalid Cursor evidence detail");
fields[d.name] = d.value;
}
if (!id(fields.toolCallId) || !enums.stage.includes(fields.stage!) || origin.eventType !== fields.stage || origin.method !== (fields.stage === "tool" ? "session/update" : "session/request_permission")) throw new Error("Invalid Cursor evidence origin");
for (const [key, values] of Object.entries(enums)) if (fields[key] !== undefined && !values.includes(fields[key]!)) throw new Error("Invalid Cursor evidence enum");
if (fields.requestId !== undefined && !id(fields.requestId)) throw new Error("Invalid Cursor request identity");
if (fields.commandSha256 !== undefined && !/^sha256:[a-f0-9]{64}$/u.test(fields.commandSha256)) throw new Error("Invalid Cursor command digest");
if (fields.declineOffered !== undefined && !["true", "false"].includes(fields.declineOffered)) throw new Error("Invalid Cursor offered choice");
result.push({ ...fields, runId, sessionId: origin.sessionId, turnId: origin.turnId, seq: row.seq, declineOffered: fields.declineOffered === "true" } as CursorToolNotice);
}
if (new Set(result.map(row => row.seq)).size !== result.length) throw new Error("Duplicate Cursor evidence sequence");
return result;
}
/** Exact absolute target removes any dependency on implicit native shell cwd. */
export function cursorDeniedCommand(path: string) {
if (!isAbsolute(path) || /[\u0000-\u001f\u007f]/u.test(path)) throw new Error("Invalid denial target");
const command = `printf 'MUST_NOT_EXIST' > '${path.replaceAll("'", "'\\''")}'`;
return { command, commandSha256: `sha256:${createHash("sha256").update(command).digest("hex")}` };
}
export function hasCursorDeniedCommand(input: {
notices: readonly CursorToolNotice[]; runId: string; turnId: string; requestId: string; toolCallId: string; commandSha256: string;
}): boolean {
const notices = input.notices;
const requests = notices.filter(row => row.stage === "permission_requested");
if (requests.length !== 1) return false;
const request = requests[0]!;
const same = (row: CursorToolNotice) => row.runId === input.runId && row.turnId === input.turnId && row.toolCallId === input.toolCallId && row.sessionId === request.sessionId && row.commandSha256 === input.commandSha256 && row.operation === "execute";
if (!same(request) || request.requestId !== input.requestId || !request.declineOffered) return false;
const origins = notices.filter(row => row.stage === "tool" && row.status === "pending");
const delivered = notices.filter(row => row.stage === "permission_delivered");
const failed = notices.filter(row => row.stage === "tool" && row.status === "failed");
return origins.length === 1 && same(origins[0]!) && delivered.length === 1 && same(delivered[0]!)
&& delivered[0]!.requestId === input.requestId && delivered[0]!.outcome === "reject_once"
&& failed.length === 1 && same(failed[0]!) && origins[0]!.seq < request.seq && request.seq < delivered[0]!.seq && delivered[0]!.seq < failed[0]!.seq
&& notices.every(row => same(row) && row.status !== "completed");
}
export function hasCursorCancellation(input: { run: unknown; issue: unknown; events: readonly unknown[]; runId: string; turnId: string; requestedAt: number }): boolean {
const run = rec(input.run), cancellation = rec(rec(run.resultJson).nativeCancellation);
const terminals = input.events.map(rec).map(row => ({ row, event: rec(rec(row.payload).prpEvent) })).filter(({ event }) => ["turn.cancelled", "turn.interrupted"].includes(event.eventType));
return run.id === input.runId && run.status === "cancelled" && rec(input.issue).status === "in_progress"
&& cancellation.dispatchState === "acknowledged" && cancellation.dispatched === true && cancellation.scope === "run"
&& terminals.length === 1 && terminals.every(({ row, event }) => row.runId === input.runId && event.runId === input.runId && event.turnId === input.turnId
&& event.schema === "paperclip.prp.event.v1" && event.schemaVersion === 1 && row.protocolSchemaVersion === 1 && event.sourceKind === "runner"
&& Number.isFinite(input.requestedAt) && Date.parse(event.emittedAt) >= input.requestedAt);
}
@@ -12,6 +12,7 @@ it("keeps native mode/permission choices explicit and artifact export pending",
}
expect(cursorNativeCaseDesigns.filter(row => row.method !== "session/request_permission").every(row => row.cursorMode === "plan")).toBe(true);
expect(cursorNativeCaseDesigns.find(row => row.method === "session/request_permission")).toMatchObject({ cursorMode: "agent", permissionMode: "approve-reads" });
expect(cursorNativeTasks.find(task => task.id === "native-write-deny-reconnect")!.expectedTerminalState).toEqual({ issue: "in_progress", run: "cancelled" });
expect(cursorNativePlanArtifactGate.status).toBe("pending");
expect(cursorNativePlanArtifactGate.nativePath).toContain("<private provider HOME>");
});
+38 -16
View File
@@ -6,6 +6,7 @@ import { pollUntil, type RunnerApi } from "./api.js";
import { collectRunEvents } from "./run-observations.js";
import { createTaskThroughUi } from "./user-actions.js";
import { observeRunProcesses, watchDeniedTarget } from "./copilot-local-fixtures.js";
import { cursorDeniedCommand, hasCursorDeniedCommand, hasCursorCancellation, readCursorToolEvidence, type CursorToolNotice } from "./cursor-native-evidence.js";
import { cursorNativeCaseDesigns, cursorNativePlanArtifactGate, hasCursorDenialBoundary, hasCursorPlanDecision, hasDeliveredCursorNativeRequest, hasExactCursorNativeResponse, type CursorNativeMethod } from "./cursor-native-cases.js";
import type { LiveFixtureValues } from "./live-fixtures.js";
import type { MatrixExecution } from "./types.js";
@@ -49,8 +50,9 @@ export async function runCursorNativeFlow(input: {
const events = (runId: string) => collectRunEvents<Row>((afterSeq, limit) => api.get(`/api/heartbeat-runs/${runId}/events?afterSeq=${afterSeq}&limit=${limit}`));
const absent = async (path: string) => { try { await lstat(path); return false; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; throw error; } };
const agent = await api.get<Row>(`/api/agents/${fixtures.agent.id}`);
const configured = await api.patch<Row>(`/api/agents/${fixtures.agent.id}`, { adapterConfig: { ...agent.adapterConfig, acpxSessionMode: design.cursorMode, acpxPermissionMode: design.permissionMode } });
const configured = await api.patch<Row>(`/api/agents/${fixtures.agent.id}`, { adapterConfig: { ...agent.adapterConfig, acpxSessionMode: design.cursorMode, acpxPermissionMode: design.permissionMode, ...(design.id === "native-write-deny-reconnect" ? { lifecycleMode: "per_turn", timeoutSec: 120 } : {}) } });
check("explicit-mode-policy", configured.adapterConfig.acpxSessionMode === design.cursorMode && configured.adapterConfig.acpxPermissionMode === design.permissionMode, "Public agent configuration selected mode and permission policy separately before provider startup");
if (design.id === "native-write-deny-reconnect") check("per-turn-process-authority", configured.adapterConfig.lifecycleMode === "per_turn" && configured.adapterConfig.timeoutSec === 120, "Public configuration admits a bounded per-turn provider process before startup");
await input.evidence("cursor-native-contract.json", { caseId: design.id, mode: design.cursorMode, permissionMode: design.permissionMode, method: design.method, expectedRunCount: 1, nativeCallbackRequired: true, artifactGate: cursorNativePlanArtifactGate });
const project = await api.post<Row>(`/api/companies/${fixtures.company.id}/projects`, {
name: `Cursor native workspace ${nonce}`, executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", sharedWorkspaceConcurrency: "serialize", allowIssueOverride: false, environmentId: fixtures.environment.id, workspaceStrategy: { type: "project_primary" } },
@@ -63,6 +65,8 @@ export async function runCursorNativeFlow(input: {
check(`workspace-unchanged-${phase}`, JSON.stringify(current) === JSON.stringify(baseline), "Independent workspace bytes remain unchanged by a pending/rejected/cancelled native plan or question");
};
const deniedPath = join(input.workspacePath, `cursor-denied-${nonce}.txt`);
const deniedCommand = cursorDeniedCommand(deniedPath);
let denialNotices: CursorToolNotice[] = []; let denialTurnId = ""; let cancelRequestedAt = NaN; let cancellationProven = false;
const samples: Array<{ phase: string; path: string; absent: boolean; observedAt: number }> = [];
const sampleDenied = async (phase: string) => { const sample = { phase, path: deniedPath, absent: await absent(deniedPath), observedAt: Date.now() }; samples.push(sample); await input.evidence("cursor-denial-samples.json", samples); check(`denied-absent-${phase}`, sample.absent, "Independent denied target remains absent"); };
const processObserver = observeRunProcesses(); let processAuthority: string | null = null; let processObservationError = false;
@@ -92,20 +96,20 @@ export async function runCursorNativeFlow(input: {
processes = await pollUntil({ label: "observed Cursor provider retirement", deadlineAt: Date.now() + 5_000,
load: async () => observeProcesses(), accept: observation => observation.live.length === 0 });
}
const settled = runs.length === 1 && ["succeeded", "failed", "cancelled", "timed_out"].includes(runs[0]!.status);
const settled = runs.length === 1 && runs[0]!.status === "cancelled" && issue.status === "in_progress" && cancellationProven;
finalCheck("authoritative-provider-cleanup", settled && !processObservationError && processes.captured && processes.live.length === 0, "Exact API-bound per-turn process/start/group and observed descendants have retired");
const finalSample = { phase: "after-cleanup", path: deniedPath, absent: await absent(deniedPath), observedAt: Date.now() };
samples.push(finalSample);
finalCheck("denied-absent-after-cleanup", finalSample.absent, "Independent target remains absent after observed provider retirement");
const journal = watch.finish();
finalCheck("continuous-denial-observation", journal.complete && journal.targetMutationCount === 0, "Continuous target watcher observed no create/delete mutation and retained directory identity");
finalCheck("complete-native-denial-boundary", Boolean(deniedRequest) && hasCursorDenialBoundary({ request: deniedRequest, expectedRequestId: deniedRequest?.requestId ?? "", expectedToolCallId: deniedRequest?.details.toolCallId ?? "", path: deniedPath, samples }), "Supported native denial preserved the target through all six independent boundaries");
await input.evidence("cursor-native-denial-final.json", { request: deniedRequest, samples, processes, processObservationError, watcher: journal, checks: cleanupChecks });
finalCheck("complete-native-denial-boundary", Boolean(deniedRequest) && hasCursorDenialBoundary({ request: deniedRequest, expectedRequestId: deniedRequest?.requestId ?? "", expectedToolCallId: deniedRequest?.details.toolCallId ?? "", path: deniedPath, samples, notices: denialNotices, runId: runs[0]?.id ?? "", turnId: denialTurnId }), "Supported native denial preserved the target through all six independent boundaries");
await input.evidence("cursor-native-denial-final.json", { request: deniedRequest, samples, notices: denialNotices, commandSha256: deniedCommand.commandSha256, cancellationProven, cancelRequestedAt, processes, processObservationError, watcher: journal, checks: cleanupChecks });
if (cleanupChecks.some(row => !row.passed)) throw new Error("Cursor native denial cleanup proof is incomplete or observed an effect");
return cleanupChecks;
} finally {
clearInterval(processTimer);
await input.evidence("cursor-native-denial-cleanup-attempt.json", { request: deniedRequest, samples, processes, processObservationError, watcher: watch.finish(), checks: cleanupChecks });
await input.evidence("cursor-native-denial-cleanup-attempt.json", { request: deniedRequest, samples, notices: denialNotices, commandSha256: deniedCommand.commandSha256, cancellationProven, cancelRequestedAt, processes, processObservationError, watcher: watch.finish(), checks: cleanupChecks });
}
});
}
@@ -117,6 +121,7 @@ export async function runCursorNativeFlow(input: {
if (watch) processes = observeProcesses();
const interactions = await api.get<Row[]>(`/api/issues/${issue.id}/interactions`);
const runEvents = runs.length === 1 ? await events(runs[0]!.id) : [];
if (watch && runs.length === 1) denialNotices = readCursorToolEvidence(runEvents, runs[0]!.id);
return { issue, runs, interactions, runEvents };
};
const reject = (state: Awaited<ReturnType<typeof load>>) => state.runs.length > 1 ? "Unexpected extra Cursor provider run" : state.runs.some(run => ["failed", "cancelled", "timed_out"].includes(run.status)) ? "Cursor provider run failed" : undefined;
@@ -144,7 +149,7 @@ export async function runCursorNativeFlow(input: {
let expectedMarker = execution.task.buildVisibleMarker(nonce);
try {
if (design.id === "native-write-deny-reconnect") await sampleDenied("before-request");
await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: execution.task.buildPrompt(nonce), workMode: "standard", projectName: project.name });
await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: execution.task.buildPrompt(nonce) + (watch ? `\nExact native shell command (copy verbatim):\n${deniedCommand.command}` : ""), workMode: "standard", projectName: project.name });
issue = await pollUntil({ label: "browser-created Cursor task", deadlineAt: input.deadlineAt, load: async () => (await api.get<Row[]>(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(row => row.title === execution.task.buildTitle(nonce)), accept: Boolean }) ?? {};
if (!issue.id) throw new Error("Browser-created Cursor task is absent");
await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`);
@@ -186,25 +191,42 @@ export async function runCursorNativeFlow(input: {
}
await input.evidence("cursor-native-artifact-gap.json", cursorNativePlanArtifactGate);
} else {
const state = await pollUntil({ label: "native Cursor permission", deadlineAt: input.deadlineAt, load, reject,
accept: state => Boolean(createdRequest(state.runEvents, "session/request_permission")) });
const event = createdRequest(state.runEvents, "session/request_permission")!; const request = event.payload.request; deniedRequest = request;
check("native-permission-identity", state.runs.length === 1 && typeof request.details?.toolCallId === "string" && request.choices.some((choice: Row) => choice.key === "decline"), "Presented native permission carries its tool identity and supported denial choice");
const state = await pollUntil({ label: "native Cursor permission with exact command provenance", deadlineAt: input.deadlineAt, load, reject,
accept: state => denialNotices.some(notice => notice.stage === "permission_requested" && notice.commandSha256 === deniedCommand.commandSha256
&& Boolean(createdRequest(state.runEvents, "session/request_permission", notice.requestId))) });
const native = denialNotices.find(notice => notice.stage === "permission_requested" && notice.commandSha256 === deniedCommand.commandSha256)!;
const event = createdRequest(state.runEvents, "session/request_permission", native.requestId)!; const request = event.payload.request; deniedRequest = request; denialTurnId = event.turnId;
check("native-permission-identity", state.runs.length === 1 && request.details?.toolCallId === native.toolCallId && native.turnId === event.turnId && native.declineOffered && request.choices.some((choice: Row) => choice.key === "decline"), "Presented native permission is bound to the exact absolute-target command and supported denial choice");
await sampleDenied("pending"); await page.reload();
const reloaded = await load(); check("permission-reconnect", Boolean(createdRequest(reloaded.runEvents, "session/request_permission", request.requestId)) && !reloaded.runEvents.some(row => row.payload?.prpEvent?.eventType === "runtime_request.resolved"), "Reconnect preserves the unresolved native permission");
await sampleDenied("browser-reconnected"); await input.capture("cursor-permission", "Native write permission awaiting denial", "cursor-permission.png");
const card = page.getByTestId("task-chat-runtime-request").filter({ visible: true }); await expect(card).toHaveCount(1);
const label = request.choices.find((choice: Row) => choice.key === "decline").label;
await page.getByRole("button", { name: label, exact: true }).last().click();
const identity = { runId: runs[0]!.id, turnId: event.turnId, requestId: request.requestId, method: "session/request_permission" as const, action: "decline" as const };
await pollUntil({ label: "native denial delivery", deadlineAt: input.deadlineAt, load, reject, accept: state => hasDeliveredCursorNativeRequest({ ...identity, events: state.runEvents }) });
const route = `/api/heartbeat-runs/${native.runId}/runtime-requests/${encodeURIComponent(request.requestId)}/resolve`;
const sent = page.waitForRequest(row => new URL(row.url()).pathname === route && row.method() === "POST");
await card.getByRole("button", { name: label, exact: true }).click(); const posted = (await sent).postDataJSON();
check("browser-exact-denial", posted.turnId === event.turnId && posted.requestKind === "permission_approval" && posted.resolution?.action === "decline", "Browser denied the exact native run/request/turn");
const identity = { runId: native.runId, turnId: event.turnId, requestId: request.requestId, method: "session/request_permission" as const, action: "decline" as const };
await pollUntil({ label: "native denial delivered and exact command failed", deadlineAt: input.deadlineAt, load, reject, accept: state => hasDeliveredCursorNativeRequest({ ...identity, events: state.runEvents })
&& hasCursorDeniedCommand({ notices: denialNotices, ...identity, toolCallId: native.toolCallId, commandSha256: deniedCommand.commandSha256 }) });
await sampleDenied("after-decision");
cancelRequestedAt = Date.now(); await api.post(`/api/heartbeat-runs/${native.runId}/cancel`);
const cancelled = await pollUntil({ label: "explicit native cancellation", deadlineAt: input.deadlineAt, load,
reject: state => state.runs.length !== 1 || ["failed", "succeeded", "timed_out"].includes(state.runs[0]?.status) ? "Cursor denial did not remain cancellable" : undefined,
accept: state => hasCursorCancellation({ run: state.runs[0], issue: state.issue, events: state.runEvents, runId: native.runId, turnId: event.turnId, requestedAt: cancelRequestedAt }) });
cancellationProven = true; await sampleDenied("after-terminal");
check("negative-task-unfinished", cancelled.issue.status === "in_progress" && cancelled.runs[0]?.status === "cancelled" && cancelled.runs[0]?.runtimeMode === "native", "Native cancellation was acknowledged and the task does not falsely claim completion");
await page.reload();
await expect(page.getByTestId("issue-detail-header").getByRole("button", { name: "Change status (current: In Progress)", exact: true })).toBeVisible();
const comments = await api.get<Row[]>(`/api/issues/${issue.id}/comments`);
await input.evidence("api-state.json", { ...cancelled, run: runs[0], comments, checks, notices: denialNotices, commandSha256: deniedCommand.commandSha256, cancelRequestedAt, runEventsByRun: [{ runId: native.runId, events: cancelled.runEvents }] });
await input.capture("final-state", "Cursor denied command cancelled; task remains unfinished", "final-state.png");
return { issue, runs, checks };
}
const final = await pollUntil({ label: "Cursor native completion", deadlineAt: input.deadlineAt, load, reject,
accept: state => state.issue.status === "done" && state.runs.length === 1 && state.runs[0]!.status === "succeeded" && !state.interactions.some(card => card.status === "pending") });
check("one-native-run", final.runs[0]!.runtimeMode === "native", "Decision and completion remained in the original native provider run");
if (design.id === "native-write-deny-reconnect") await sampleDenied("after-terminal");
else if (design.id === "native-plan-cancel" || design.id === "native-question-reconnect") await sampleWorkspace("native-terminal");
if (design.id === "native-plan-cancel" || design.id === "native-question-reconnect") await sampleWorkspace("native-terminal");
await page.reload(); await expect(page.getByText(expectedMarker, { exact: true }).last()).toBeVisible();
await expect(page.getByTestId("issue-detail-header").getByRole("button", { name: "Change status (current: Done)", exact: true })).toBeVisible();
const comments = await api.get<Row[]>(`/api/issues/${issue.id}/comments`);