fix(runner): retain strict active-stop evidence diagnostics

Accept matching PRP v1/v2 session envelopes without weakening pending-operation identity or cancellation checks. Record closed Cursor projection failure codes while keeping external error text private and incomplete evidence disqualifying.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-30 21:04:20 -05:00
1 parent 8ffecf0fc1
commit 64dd389ebe
4 files changed
+109 -20

No files matched your search

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