mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-08 00:54:38 +02:00
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:
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({
|
||||
|
||||
Reference in new issue
Block a user