fix(runner): correlate Copilot semantic tool results with bounded receipts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-30 21:51:20 -05:00
1 parent ae0c0f9f71
commit 4eca3e8a02
13 files changed
+406 -21

No files matched your search

@@ -1,3 +1,4 @@
import { appendSemanticToolReceipt } from "../drivers/semantic-tool-receipt.js";
import { acpxUsageEstimateNotice, persistedAcpxTurnUsage, persistedCursorUsageNotice } from "../drivers/acpx/usage-accounting.js";
import { stripTypeScriptTypes } from "node:module";
import { cursorPlanToolIdentity, cursorToolIdentity } from "../drivers/acpx/cursor-plan-tool-identity.js";
@@ -175,10 +176,10 @@ describe("qualified ACPX runtime sidecar", () => {
const emitted: unknown[] = [];
const create = new Function("createCopilotToolEvidence", "createCursorToolEvidence", "validateAcpxRichEvent", "emit", "agent", `
const activeHost = { identity: () => ({ backendSessionId: "session" }) };
let host = activeHost, turnId = "turn";
let host = activeHost, turnId = "turn", activeCopilotEvidence;
const currentTurnId = "turn", openParams = { agent, workingDirectory: "/workspace" };
const diagnostic = () => {};
${source.slice(start, end).replaceAll("openParams!", "openParams")}
${stripTypeScriptTypes(source.slice(start, end))}
return { evidence: toolEvidence, retire: () => { turnId = null; } };
`)(createCopilotToolEvidence, createCursorToolEvidence, validateAcpxRichEvent, (...args: unknown[]) => emitted.push(args), agent);
const tool = { type: "tool_call", tag: "tool_call", toolCallId: "tool", kind: "execute", status: "pending", rawInput: { command: "printf private-value" } };
@@ -193,6 +194,25 @@ describe("qualified ACPX runtime sidecar", () => {
expect(emitted).toHaveLength(agent === "pi" ? 0 : 1);
});
it("captures the actual sidecar bridge's Copilot receipt callback before turn replacement", () => {
const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8");
const start = source.indexOf(" semanticTools: {");
const end = source.indexOf(" onGoalUpdate:", start);
expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start);
const events: unknown[] = [];
let active = true;
const evidence = createCopilotToolEvidence({ sessionId: "session-a", turnId: "turn-a", workingDirectory: "/workspace", active: () => active, emit: event => { validateAcpxRichEvent(event); events.push(event); } });
const create = new Function("params", "activeCopilotEvidence", "waitForTool", `return ({ ${source.slice(start, end)} }).semanticTools;`);
const options = create({ agent: "copilot", tools: [] }, evidence, () => undefined);
const captured = options.captureSemanticReceipt();
const receipt = appendSemanticToolReceipt({ tool: "get_task_context", callId: "1", arguments: {} }, { content: [{ type: "text", text: "{}" }] }).receipt;
captured(receipt);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ payload: { category: "paperclip_semantic_tool_receipt_v1", provenance: { sessionId: "session-a", turnId: "turn-a" } } });
active = false; captured(receipt); expect(events).toHaveLength(1);
for (const agent of ["cursor", "codex", "pi"]) expect(create({ agent, tools: [] }, evidence, () => undefined).captureSemanticReceipt).toBeUndefined();
});
it("passes only validated Pi native boundaries and history through the real text sanitizer", () => {
const source = readFileSync(fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)), "utf8");
const start = source.indexOf(' if (event.type === "text_delta") {', source.indexOf("function sanitizeRuntimeEvent"));
@@ -152,6 +152,7 @@ let failedAdmissionCleanup: Promise<void> | null = null;
let openParams: AcpxSidecarOpenParams | null = null;
let runId: string | null = null;
let turnId: string | null = null;
let activeCopilotEvidence: CopilotToolEvidence | undefined;
let sequence = 0;
let requestSequence = 0;
let closing = false;
@@ -300,6 +301,7 @@ async function dispatch(
semanticTools: {
tools: params.tools,
handler: waitForTool,
...(params.agent === "copilot" ? { captureSemanticReceipt: () => activeCopilotEvidence?.captureSemanticReceipt() } : {}),
},
onGoalUpdate: (goal) => {
// Admission can emit a snapshot before the verified host is assigned.
@@ -383,6 +385,7 @@ async function dispatch(
emit: event => { validateAcpxRichEvent(event); emit("runtime.rich_event", { ...event }, currentTurnId); },
unavailable: () => diagnostic(`${openParams!.agent}_evidence_unavailable`, "ACP tool evidence is incomplete; permission and terminal outcomes are unchanged."),
});
activeCopilotEvidence = toolEvidence && "captureSemanticReceipt" in toolEvidence ? toolEvidence as CopilotToolEvidence : undefined;
let usageBefore: unknown;
try {
usageBefore = await readSidecarHostStatusWithin(activeHost);
@@ -1,3 +1,4 @@
import { appendSemanticToolReceipt } from "../semantic-tool-receipt.js";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { cursorToolIdentity } from "./cursor-plan-tool-identity.js";
@@ -25,6 +26,31 @@ import type {
import type { AcpxRecoveryWorkspaceLease } from "./runtime-sandbox.js";
describe("Codex ACPX harness driver", () => {
it.each(["copilot", "cursor", "pi", "codex"] as const)("scopes optional semantic receipts to the actual %s invocation turn", async agent => {
const fixture = driverFixture({ agent, model: "explicit-test-model", providerPolicy: { readOnly: true } }, { runtimeEvents: [] });
const session = await fixture.driver.openSession({ runId: "run-receipt", normalizedSessionId: "session-1", workingDirectory: "/workspace" });
const first = await session.startTurn({ message: { role: "user", text: "First" } });
const capture = fixture.hostOptions!.semanticTools!.captureSemanticReceipt;
expect(typeof capture).toBe(agent === "copilot" ? "function" : "undefined");
const oldCallback = capture?.();
const bound = appendSemanticToolReceipt({ tool: "get_task_context", callId: "1", arguments: {} }, { content: [{ type: "text", text: "{}" }] });
oldCallback?.(bound.receipt);
fixture.finishTurn({ status: "completed" });
const firstEvents = await collectUntil(session.events(), "turn.completed");
const receipts = firstEvents.filter(e => e.eventType === "provider.notice.recorded" && e.payload.category === "paperclip_semantic_tool_receipt_v1");
expect(receipts).toHaveLength(agent === "copilot" ? 1 : 0);
if (agent === "copilot") expect(receipts[0]).toMatchObject({ runId: "run-receipt", turnId: first.turnId, payload: { provenance: { sessionId: "backend-1", turnId: first.turnId } } });
const transcript = JSON.parse(JSON.stringify(await session.transcript!()));
for (const event of transcript.events) validatePrpEvent(event);
expect(transcript.events.filter((e: PrpEvent) => e.payload.category === "paperclip_semantic_tool_receipt_v1")).toEqual(receipts);
await session.startTurn({ message: { role: "user", text: "Second" } });
oldCallback?.(bound.receipt);
fixture.finishTurn({ status: "completed" });
const secondEvents = await collectUntil(session.events(), "turn.completed");
expect(secondEvents.filter(e => e.payload.category === "paperclip_semantic_tool_receipt_v1")).toEqual([]);
await session.close({ reason: "receipt turn scope verified" });
});
it.each(["cursor", "copilot"] as const)("emits partial Cursor metadata only for admitted Cursor, preserving %s settlement", async agent => {
const fixture = driverFixture({ agent, model: "explicit-test-model", providerPolicy: { readOnly: true } }, { runtimeEvents: [] });
const session = await fixture.driver.openSession({ runId: "run-native-usage", normalizedSessionId: "session-1", workingDirectory: "/workspace" });
@@ -472,6 +472,7 @@ export class CodexAcpxDriver implements HarnessDriver {
: {}),
...(input.signal === undefined ? {} : { signal: input.signal }),
semanticTools: {
...(this.#options.agent === "copilot" ? { captureSemanticReceipt: () => session?.captureSemanticReceipt() } : {}),
tools: this.#options.dynamicTools ?? [],
handler: (call) => {
if (!session) {
@@ -754,6 +755,7 @@ class CodexAcpxSession implements HarnessSession {
>;
#sourceSequence = 0;
#activeTurnId: string | null = null;
#copilotToolEvidence: CopilotToolEvidence | undefined;
#semanticResult: PrpStructuredRunResult | null = null;
#semanticFingerprint: string | null = null;
#semanticCallId: string | null = null;
@@ -913,6 +915,7 @@ class CodexAcpxSession implements HarnessSession {
if (!this.#emit(event.eventType, event.payload, { turnId, itemId: event.itemId })) throw new Error("ACP tool activity could not be retained");
},
});
this.#copilotToolEvidence = toolEvidence && "captureSemanticReceipt" in toolEvidence ? toolEvidence as CopilotToolEvidence : undefined;
let turn: AcpxRuntimeTurn;
const usageBefore = await readUsageStatus(this.#host);
try {
@@ -1109,6 +1112,10 @@ class CodexAcpxSession implements HarnessSession {
return { result: "handed_off", cleanup };
}
captureSemanticReceipt() {
return this.#copilotToolEvidence?.captureSemanticReceipt();
}
async dispatchTool(call: RunnerToolCall): Promise<unknown> {
this.#assertOpen();
if (this.#pendingTerminal) {
@@ -1,3 +1,4 @@
import { appendSemanticToolReceipt } from "../semantic-tool-receipt.js";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
@@ -6,9 +7,9 @@ import type { CanonicalProviderEvent } from "../../provider-events.js";
import { createCopilotToolEvidence } from "./copilot-tool-evidence.js";
const fixture = JSON.parse(readFileSync(new URL("./fixtures/copilot-tool-evidence.json", import.meta.url), "utf8"));
const details = (e: CanonicalProviderEvent) => Object.fromEntries((e.payload.details as Array<{ name: string; value: string }>).map(d => [d.name, d.value]));
function harness(sessionId = "session") {
function harness(sessionId = "session", turnId = "turn") {
const events: CanonicalProviderEvent[] = []; let active = true;
const projector = createCopilotToolEvidence({ sessionId, turnId: "turn", workingDirectory: "/fixture/workspace", active: () => active,
const projector = createCopilotToolEvidence({ sessionId, turnId, workingDirectory: "/fixture/workspace", active: () => active,
emit: event => { validateAcpxRichEvent(event); events.push(event); } });
return { projector, events, stop: () => { active = false; } };
}
@@ -120,3 +121,49 @@ describe("Copilot active-turn tool evidence", () => {
for (let n = 0; n < 2200; n++) h.projector.tool(update("t0", "")); expect(h.events).toHaveLength(2048);
});
});
describe("Copilot authoritative semantic receipt correlation", () => {
const args = { summary: "PRIVATE", completionClaim: { objectiveSatisfied: true } };
const make = (operation = "paperclip_finish", isError = false) => appendSemanticToolReceipt(
{ tool: operation, callId: "3", arguments: args }, { content: [{ type: "text", text: '{"accepted":false}' }], ...(isError ? { isError } : {}) });
const terminal = (id: string, content: unknown, status = "completed") => ({ type: "tool_call", tag: "tool_call_update", toolCallId: id, status, rawOutput: { contents: content } });
it.each(["paperclip_finish", "report_progress", "get_task_context"])("correlates %s with trusted callback, not native display name", operation => {
const h = harness(); const bound = make(operation);
h.projector.tool({ ...tool("native", args, "other"), title: "unrelated display" });
h.projector.captureSemanticReceipt()!(bound.receipt);
h.projector.tool(terminal("native", bound.result.content));
const authoritative = h.events.find(e => e.payload.category === "paperclip_semantic_tool_receipt_v1")!;
expect(details(authoritative)).toMatchObject({ operationId: operation, callIdentitySha256: bound.receipt.callIdentitySha256, outcome: "returned" });
expect(authoritative.payload.provenance).toMatchObject({ method: "paperclip/semantic_tool_result", sessionId: "session", turnId: "turn" });
expect(details(h.events.at(-1)!)).toMatchObject({ semanticOperationId: operation, semanticOutcome: "returned", semanticResultSha256: bound.receipt.resultSha256 });
expect(JSON.stringify(h.events)).not.toMatch(/PRIVATE|accepted|completionClaim/);
});
it.each(["untrusted", "different-input", "different-result", "foreign-session", "foreign-turn", "duplicate-native", "duplicate-authority", "early-terminal", "wrong-status"])("rejects %s authority", scenario => {
const h = harness(), other = harness(scenario === "foreign-turn" ? "session" : "foreign", scenario === "foreign-turn" ? "other-turn" : "turn"); const bound = make();
h.projector.tool({ ...tool("native", scenario === "different-input" ? {} : args, "other"), title: "paperclip_finish" });
if (scenario === "early-terminal") h.projector.tool(terminal("native", bound.result.content));
if (scenario === "foreign-session" || scenario === "foreign-turn") other.projector.captureSemanticReceipt()!(bound.receipt);
else if (scenario !== "untrusted") h.projector.captureSemanticReceipt()!(bound.receipt);
if (scenario === "duplicate-authority") h.projector.captureSemanticReceipt()!(bound.receipt);
if (scenario === "duplicate-native") {
h.projector.tool(terminal("native", bound.result.content));
h.projector.tool(tool("second", args, "other"));
}
const output = structuredClone(bound.result.content);
if (scenario === "different-result") output[0]!.text = "changed";
h.projector.tool(terminal(scenario === "duplicate-native" ? "second" : "native", output, scenario === "wrong-status" ? "failed" : "completed"));
expect(details(h.events.at(-1)!)).not.toHaveProperty("semanticOperationId");
if (scenario === "duplicate-native") expect(h.events.map(details)).toContainEqual(expect.objectContaining({ reason: "semantic_receipt_conflict" }));
});
it("preserves admitted errors and fences late captured callbacks instead of rebinding", () => {
const h = harness(); const bound = make("get_task_context", true);
const captured = h.projector.captureSemanticReceipt()!;
h.projector.tool(tool("native", args, "other")); captured(bound.receipt);
h.projector.tool(terminal("native", bound.result.content, "failed"));
expect(details(h.events.at(-1)!)).toMatchObject({ semanticOutcome: "error", status: "failed" });
const count = h.events.length; h.stop(); captured(bound.receipt);
expect(h.events).toHaveLength(count);
const next = harness(); next.projector.tool(tool("native", args, "other")); next.projector.tool(terminal("native", bound.result.content, "failed"));
expect(details(next.events.at(-1)!)).not.toHaveProperty("semanticOperationId");
});
});
@@ -1,3 +1,4 @@
import { parseSemanticToolReceipt, readNativeSemanticReceipt, sameSemanticReceipt, semanticInputSha256, type SemanticToolReceipt } from "../semantic-tool-receipt.js";
import { updateSingleReadEvidence, type SingleReadEvidence } from "./single-read-evidence.js";
import { createHash } from "node:crypto";
import { redactPaperclipSemanticValue } from "../../semantic-tools/redaction.js";
@@ -7,7 +8,7 @@ import { safeCopilotEditTarget } from "./copilot-permission-context.js";
const LIMIT = 256;
const CATEGORY = "copilot_tool_evidence_v1";
type Fields = Record<string, string | number | boolean>;
interface Tool { kind?: string; input?: string; fields: Fields; invalid?: boolean; read?: SingleReadEvidence }
interface Tool { kind?: string; input?: string; fields: Fields; invalid?: boolean; semanticInput?: string; semanticReceipt?: SemanticToolReceipt; read?: SingleReadEvidence }
const record = (v: unknown): Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record<string, unknown> : {};
const identity = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v);
const shellIdentity = (v: unknown): v is string => typeof v === "string" && /^[A-Za-z0-9_.-]{1,80}$/u.test(v);
@@ -27,6 +28,8 @@ export function createCopilotToolEvidence(binding: {
const tools = new Map<string, Tool>();
const shells = new Map<string, string | null>();
const permissionTools = new Set<string>();
const semanticReceipts = new Map<string, SemanticToolReceipt | null>();
const matchedReceipts = new Map<string, string>();
let sequence = 0;
let incomplete = false;
let broken = false;
@@ -40,7 +43,7 @@ export function createCopilotToolEvidence(binding: {
return;
}
}
function notice(stage: string, toolCallId: string, fields: Fields, method = "session/update") {
function notice(stage: string, toolCallId: string | undefined, fields: Fields, method = "session/update", category = CATEGORY) {
if (!binding.active() || !identity(binding.sessionId) || !identity(binding.turnId)) return;
if (sequence >= 2048) {
if (incomplete) return;
@@ -49,8 +52,8 @@ export function createCopilotToolEvidence(binding: {
const itemId = `copilot-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: CATEGORY, scope: "turn", recoverable: true, userActionable: false,
summary: stage === "evidence_incomplete" ? "Some Copilot activity details are unavailable."
category, scope: "turn", recoverable: true, userActionable: false,
summary: stage === "semantic_result" ? "Paperclip returned a semantic tool result." : stage === "evidence_incomplete" ? "Some Copilot activity details are unavailable."
: stage === "permission_requested" ? "Copilot requested permission."
: stage === "permission_delivered" ? (fields.outcome === "reject_once" ? "Copilot received your denial." : "Copilot received your permission decision.")
: fields.shellState === "started" ? (fields.detach === false ? "Copilot started an attached command." : "Copilot started a background command.")
@@ -58,7 +61,7 @@ export function createCopilotToolEvidence(binding: {
: fields.operation === "edit" ? `Copilot file operation: ${fields.status}.`
: `Copilot tool operation: ${fields.status}.`,
provenance: { method, eventType: stage, sessionId: binding.sessionId, turnId: binding.turnId },
details: Object.entries({ stage, toolCallId, ...fields }).map(([name, value]) => ({ name, value: String(value) })),
details: Object.entries({ stage, ...(toolCallId === undefined ? {} : { toolCallId }), ...fields }).map(([name, value]) => ({ name, value: String(value) })),
}) as Record<string, unknown> });
}
function inputFields(call: Record<string, unknown>): Fields {
@@ -93,12 +96,18 @@ export function createCopilotToolEvidence(binding: {
state = { fields: {} }; tools.set(id, state);
} else if (call.tag === "tool_call") { state.invalid = true; notice("evidence_incomplete", id, { reason: "reused_tool_origin" }); }
if (state.invalid) return;
if (state.semanticReceipt) { state.invalid = true; notice("evidence_incomplete", id, { reason: "reused_semantic_lifecycle" }); return; }
if (typeof call.kind === "string") {
if (state.kind && state.kind !== call.kind) { state.invalid = true; notice("evidence_incomplete", id, { reason: "changed_tool_kind" }); return; }
state.kind = call.kind;
}
if (state.kind === "read") state.read = updateSingleReadEvidence(state.read, call, binding.workingDirectory);
if (call.rawInput !== undefined) {
// Retain only a bounded digest of the native arguments, never their text.
let inputDigest: string | undefined;
try { if (Buffer.byteLength(JSON.stringify(call.rawInput)) <= 1024 * 1024) inputDigest = semanticInputSha256(call.rawInput); } catch { /* Malformed input cannot correlate. */ }
if (state.semanticInput && inputDigest !== state.semanticInput) { state.invalid = true; notice("evidence_incomplete", id, { reason: "changed_tool_input" }); return; }
state.semanticInput = inputDigest;
const fields = inputFields({ ...call, kind: state.kind });
const fingerprint = JSON.stringify(fields);
if (state.input && state.input !== fingerprint) { state.invalid = true; notice("evidence_incomplete", id, { reason: "changed_tool_input" }); return; }
@@ -127,6 +136,27 @@ export function createCopilotToolEvidence(binding: {
}
}
}
if (call.rawOutput !== undefined) {
const candidate = readNativeSemanticReceipt(call.rawOutput);
if (candidate) {
const authoritative = semanticReceipts.get(candidate.callIdentitySha256);
const previous = matchedReceipts.get(candidate.callIdentitySha256);
if ((status !== "completed" && status !== "failed") || !authoritative
|| !sameSemanticReceipt(authoritative, candidate) || state.semanticInput !== candidate.inputSha256
|| (status === "failed") !== (candidate.outcome === "error") || previous !== undefined) {
state.invalid = true;
notice("evidence_incomplete", id, { reason: "semantic_receipt_conflict" });
return;
}
matchedReceipts.set(candidate.callIdentitySha256, id);
state.semanticReceipt = candidate;
fields.semanticOperationId = candidate.operationId;
fields.semanticCallIdentitySha256 = candidate.callIdentitySha256;
fields.semanticInputSha256 = candidate.inputSha256;
fields.semanticResultSha256 = candidate.resultSha256;
fields.semanticOutcome = candidate.outcome;
}
}
notice("tool", id, fields);
},
permission(request: unknown, requestId: string, offeredActions: readonly string[]) {
@@ -148,6 +178,21 @@ export function createCopilotToolEvidence(binding: {
},
};
return {
captureSemanticReceipt(): ((receipt: SemanticToolReceipt) => void) | undefined {
if (!binding.active()) return undefined;
// This closure belongs to this turn, even if a successor becomes active.
return receipt => { safely(() => {
if (!binding.active()) return;
const parsed = parseSemanticToolReceipt(receipt);
if (!parsed || semanticReceipts.size >= LIMIT || semanticReceipts.has(parsed.callIdentitySha256)) {
if (parsed && semanticReceipts.has(parsed.callIdentitySha256)) semanticReceipts.set(parsed.callIdentitySha256, null);
notice("evidence_incomplete", "semantic", { reason: "ambiguous_semantic_receipt" });
return;
}
semanticReceipts.set(parsed.callIdentitySha256, parsed);
notice("semantic_result", undefined, { ...parsed }, "paperclip/semantic_tool_result", "paperclip_semantic_tool_receipt_v1");
}); };
},
tool: (event: unknown) => { safely(() => projection.tool(event)); },
permission: (request: unknown, requestId: string, offeredActions: readonly string[]) => safely(() => projection.permission(request, requestId, offeredActions)),
};
@@ -1,3 +1,7 @@
import { createCopilotToolEvidence } from "./copilot-tool-evidence.js";
import { readNativeSemanticReceipt, type SemanticToolResult } from "../semantic-tool-receipt.js";
import { validateAcpxRichEvent } from "./profile-extensions.js";
import type { CanonicalProviderEvent } from "../../provider-events.js";
import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -736,6 +740,30 @@ describe("ACPX runtime host", () => {
expect(fixture.commandClose).toHaveBeenCalledOnce();
});
it("forwards semantic receipt capture through the real runtime host and authenticated bridge", async () => {
const fixture = await hostFixture();
const events: CanonicalProviderEvent[] = [];
const evidence = createCopilotToolEvidence({ sessionId: "backend-1", turnId: "turn-1", workingDirectory: fixture.options.workingDirectory,
active: () => true, emit: event => { validateAcpxRichEvent(event); events.push(JSON.parse(JSON.stringify(event))); } });
let bridge: AcpxRuntimePortOpenOptions["mcpServers"][number] | undefined;
const host = await AcpxRuntimeHost.open({ ...fixture.options, agent: "copilot", model: "gpt-5.6-sol",
permissionMode: "deny-all", environment: { COPILOT_GITHUB_TOKEN: "fixture-not-a-real-credential" },
semanticTools: { tools: [{ name: "get_task_context", inputSchema: { type: "object", properties: {}, additionalProperties: false } }],
handler: async () => ({ accepted: false }), captureSemanticReceipt: () => evidence.captureSemanticReceipt() },
}, fixture.dependencies({ openRuntime: async options => { bridge = options.mcpServers[0]; return runtimePort(); } }));
try {
const response = await fetch(bridge!.url, { method: "POST", headers: { Authorization: `Bearer ${bridge!.bearerToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "get_task_context", arguments: {} } }) });
const body = await response.json() as { result: SemanticToolResult };
const receipt = readNativeSemanticReceipt({ contents: body.result.content });
expect(receipt).not.toBeNull();
expect(events).toHaveLength(1);
expect(events[0]!.payload).toMatchObject({ category: "paperclip_semantic_tool_receipt_v1", provenance: { sessionId: "backend-1", turnId: "turn-1" },
details: expect.arrayContaining([{ name: "callIdentitySha256", value: receipt!.callIdentitySha256 }, { name: "resultSha256", value: receipt!.resultSha256 }]) });
} finally { await host.close({ reason: "receipt forwarding verified" }); }
await expect(fetch(bridge!.url)).rejects.toThrow();
});
it("owns an authenticated semantic bridge without persisting its secret", async () => {
const fixture = await hostFixture();
const handler = vi.fn(async ({ tool }) => ({ tool, ok: true }));
@@ -1,3 +1,4 @@
import { readNativeSemanticReceipt, type SemanticToolReceipt, type SemanticToolResult } from "./semantic-tool-receipt.js";
import { connect } from "node:net";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -465,3 +466,44 @@ describe("runner semantic MCP bridge", () => {
function tool(name: string): Readonly<Record<string, unknown>> {
return { name, inputSchema: { type: "object" } };
}
describe("Copilot semantic receipt opt-in", () => {
it.each(["small", "chunked", "tagged", "error"])("preserves %s result encoding and captures scope before dispatch", async encoding => {
const receipts: SemanticToolReceipt[] = [];
let resolve!: (value: unknown) => void;
let reject!: (error: Error) => void;
const pending = new Promise((yes, no) => { resolve = yes; reject = no; });
const capture = vi.fn(() => (receipt: SemanticToolReceipt) => receipts.push(receipt));
const handler = vi.fn(() => pending);
const bridge = await startRunnerToolBridge({ tools: [tool("documents.read")], handler, captureSemanticReceipt: capture });
bridges.push(bridge);
const request = { id: 3, method: "tools/call", params: { name: "documents.read", arguments: {} } };
const response = rpc(bridge, request);
await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
expect(capture).toHaveBeenCalledTimes(1);
if (encoding === "error") reject(new Error("bounded failure"));
else resolve(encoding === "chunked" ? "x".repeat(70000) : encoding === "tagged" ? { bigint: 1n } : { accepted: false });
const body = await (await response).json() as { result: SemanticToolResult };
expect(receipts).toHaveLength(1);
expect(readNativeSemanticReceipt({ contents: body.result.content })).toEqual(receipts[0]);
expect(body.result.isError === true).toBe(encoding === "error");
if (encoding === "small") expect(JSON.parse(body.result.content[0].text)).toEqual({ accepted: false });
if (encoding === "error") expect(body.result.content[0].text).toBe("bounded failure");
if (encoding === "chunked") expect(body.result.content.length).toBeGreaterThan(2);
const replay = await (await rpc(bridge, request)).json();
expect(replay).toEqual(body);
expect(receipts).toHaveLength(1);
expect(handler).toHaveBeenCalledTimes(1);
});
it("does not receipt unauthorized/unadmitted calls or let diagnostic errors change results", async () => {
const capture = vi.fn(() => () => { throw new Error("diagnostic only"); });
const bridge = await startRunnerToolBridge({ tools: [tool("documents.read")], handler: async () => ({ ok: true }), captureSemanticReceipt: capture });
bridges.push(bridge);
await rpc(bridge, { id: 1, method: "tools/call", params: { name: "documents.read" } }, "wrong");
await rpc(bridge, { id: 2, method: "tools/call", params: { name: "unknown" } });
expect(capture).not.toHaveBeenCalled();
const body = await (await rpc(bridge, { id: 3, method: "tools/call", params: { name: "documents.read" } })).json() as { result: SemanticToolResult };
expect(JSON.parse(body.result.content[0].text)).toEqual({ ok: true });
expect(readNativeSemanticReceipt({ contents: body.result.content })).not.toBeNull();
});
});
@@ -1,4 +1,5 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { appendSemanticToolReceipt, semanticCanonicalJson as canonicalJson, type SemanticToolReceipt } from "./semantic-tool-receipt.js";
import {
createServer,
type IncomingMessage,
@@ -33,6 +34,8 @@ export interface RunnerToolBridgeOptions {
/** Runner-owned operations that are callable but never model-visible. */
privateTools?: readonly Readonly<Record<string, unknown>>[];
handler(call: RunnerToolCall): Promise<unknown>;
/** Copilot-only opt-in; capture the active invocation scope before dispatch. */
captureSemanticReceipt?: () => ((receipt: SemanticToolReceipt) => void) | undefined;
timeoutMs?: number;
privateToolTimeoutMs?: number;
maxBodyBytes?: number;
@@ -58,6 +61,7 @@ interface RunnerToolTextContent {
}
interface RunnerToolCallResult {
isError?: boolean;
content: RunnerToolTextContent[];
}
@@ -121,6 +125,7 @@ export async function startRunnerToolBridge(
calls,
controllers,
handler: options.handler,
captureSemanticReceipt: options.captureSemanticReceipt,
timeoutMs: positiveBoundedInteger(
options.timeoutMs,
DEFAULT_TIMEOUT_MS,
@@ -190,6 +195,7 @@ async function handleRequest(
calls: Map<string, AdmittedCall>;
controllers: Map<string, AbortController>;
handler: RunnerToolBridgeOptions["handler"];
captureSemanticReceipt: RunnerToolBridgeOptions["captureSemanticReceipt"];
timeoutMs: number;
privateToolTimeoutMs: number;
privateToolNames: ReadonlySet<string>;
@@ -319,6 +325,16 @@ async function handleRequest(
}
}
const controller = existing === undefined ? new AbortController() : undefined;
let observeReceipt: ((receipt: SemanticToolReceipt) => void) | undefined;
if (!existing) { try { observeReceipt = context.captureSemanticReceipt?.(); } catch { /* Optional evidence cannot prevent dispatch. */ } }
const withReceipt = (result: RunnerToolCallResult): RunnerToolCallResult => {
if (!context.captureSemanticReceipt) return result;
try {
const bound = appendSemanticToolReceipt({ tool, callId, arguments: args }, result);
try { observeReceipt?.(bound.receipt); } catch { /* Evidence cannot change the tool outcome. */ }
return bound.result;
} catch { return result; }
};
const execution: Promise<RunnerToolCallResult> =
existing?.promise ??
withCancellationAndTimeout(
@@ -336,7 +352,10 @@ async function handleRequest(
context.privateToolNames.has(tool)
? context.privateToolTimeoutMs
: context.timeoutMs,
);
).then(withReceipt, error => {
if (!context.captureSemanticReceipt) throw error;
return withReceipt({ isError: true, content: [{ type: "text", text: safeError(error) }] });
});
if (!existing) {
context.calls.set(callKey, { fingerprint, promise: execution });
context.controllers.set(callKey, controller!);
@@ -811,16 +830,6 @@ function taggedObjectType(value: object): string {
: "Object";
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (isRecord(value)) {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
.join(",")}}`;
}
return JSON.stringify(value) ?? "undefined";
}
function isJsonContentType(value: string | undefined): boolean {
return value?.split(";", 1)[0]?.trim().toLowerCase() === "application/json";
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { appendSemanticToolReceipt, parseSemanticToolReceipt, readNativeSemanticReceipt, semanticInputSha256 } from "./semantic-tool-receipt.js";
const call = { tool: "paperclip_finish", callId: "3", arguments: { revision: "current", summary: "private" } };
const original = { content: [{ type: "text" as const, text: '{"accepted":false}' }] };
describe("bounded semantic receipt carrier", () => {
it("preserves existing content and transport-vs-semantic acceptance", () => {
const { result, receipt } = appendSemanticToolReceipt(call, original);
expect(result.content.slice(0, -1)).toEqual(original.content);
expect(receipt.outcome).toBe("returned");
expect(JSON.stringify(receipt)).not.toContain("private");
expect(readNativeSemanticReceipt({ contents: result.content })).toEqual(receipt);
expect(semanticInputSha256({ a: 1, b: 2 })).toBe(semanticInputSha256({ b: 2, a: 1 }));
});
it("preserves error content without converting error into returned acceptance", () => {
const { result, receipt } = appendSemanticToolReceipt(call, { ...original, isError: true });
expect(result.isError).toBe(true);
expect(receipt.outcome).toBe("error");
expect(readNativeSemanticReceipt({ contents: result.content })).toEqual(receipt);
});
it.each(["unknown", "malformed", "oversized", "modified", "duplicate", "missing", "type", "reordered"])("rejects %s native carrier", kind => {
const { result } = appendSemanticToolReceipt(call, original);
const blocks = structuredClone(result.content);
const receipt = JSON.parse(blocks.at(-1)!.text);
if (kind === "unknown") blocks.at(-1)!.text = JSON.stringify({ ...receipt, extra: "private" });
if (kind === "malformed") blocks.at(-1)!.text = "{";
if (kind === "oversized") blocks.at(-1)!.text = "x".repeat(2049);
if (kind === "modified") blocks[0]!.text = "different";
if (kind === "duplicate") blocks.unshift(blocks.at(-1)!);
if (kind === "missing") blocks.pop();
if (kind === "type") Object.assign(blocks[0]!, { type: "image" });
if (kind === "reordered") blocks.reverse();
expect(readNativeSemanticReceipt({ contents: blocks })).toBeNull();
});
it("bounds the whole repeated native output while still serializing large-result receipts", () => {
const bound = appendSemanticToolReceipt(call, { content: [{ type: "text", text: "x".repeat(90_000) }] });
expect(readNativeSemanticReceipt({ contents: bound.result.content })).toEqual(bound.receipt);
expect(readNativeSemanticReceipt({ contents: bound.result.content, content: "x".repeat(90_000), detailedContent: "x".repeat(90_000) })).toBeNull();
expect(bound.result.content.at(-1)!.text.length).toBeLessThan(2048);
});
it("rejects unknown fields and unsafe identities without truncation", () => {
const { receipt } = appendSemanticToolReceipt(call, original);
expect(parseSemanticToolReceipt({ ...receipt, operationId: "bad\nname" })).toBeNull();
expect(parseSemanticToolReceipt({ ...receipt, inputSha256: "a".repeat(65) })).toBeNull();
expect(parseSemanticToolReceipt({ ...receipt, outcome: "accepted" })).toBeNull();
});
});
@@ -0,0 +1,71 @@
import { createHash } from "node:crypto";
export const SEMANTIC_RECEIPT_SCHEMA = "paperclip.semantic_tool_receipt.v1";
export const MAX_SEMANTIC_RECEIPT_BYTES = 2048;
const MAX_NATIVE_BYTES = 256 * 1024;
export interface SemanticToolReceipt {
schema: typeof SEMANTIC_RECEIPT_SCHEMA;
operationId: string;
callIdentitySha256: string;
inputSha256: string;
resultSha256: string;
/** Transport outcome only: a returned value may explicitly reject a request. */
outcome: "returned" | "error";
}
export interface SemanticToolResult {
content: Array<{ type: "text"; text: string }>;
isError?: boolean;
}
const hash = (value: string) => createHash("sha256").update(value).digest("hex");
const object = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value);
/** Same canonical JSON used by the bridge's admitted-call fingerprint. */
export function semanticCanonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(semanticCanonicalJson).join(",")}]`;
if (object(value)) return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${semanticCanonicalJson(value[key])}`).join(",")}}`;
return JSON.stringify(value) ?? "undefined";
}
export const semanticInputSha256 = (value: unknown): string => hash(semanticCanonicalJson(value));
export function parseSemanticToolReceipt(value: unknown): SemanticToolReceipt | null {
if (!object(value) || Object.keys(value).sort().join(",") !== "callIdentitySha256,inputSha256,operationId,outcome,resultSha256,schema"
|| value.schema !== SEMANTIC_RECEIPT_SCHEMA
|| typeof value.operationId !== "string" || !/^[A-Za-z0-9_.:-]{1,256}$/.test(value.operationId)
|| ![value.callIdentitySha256, value.inputSha256, value.resultSha256].every(v => typeof v === "string" && /^[a-f0-9]{64}$/.test(v))
|| (value.outcome !== "returned" && value.outcome !== "error")
|| Buffer.byteLength(JSON.stringify(value)) > MAX_SEMANTIC_RECEIPT_BYTES) return null;
return { schema: SEMANTIC_RECEIPT_SCHEMA, operationId: value.operationId,
callIdentitySha256: value.callIdentitySha256 as string, inputSha256: value.inputSha256 as string,
resultSha256: value.resultSha256 as string, outcome: value.outcome };
}
function resultDigest(result: SemanticToolResult): string {
return semanticInputSha256({ content: result.content, isError: result.isError === true });
}
/** Append, never rewrite, the original admitted result encoding and content. */
export function appendSemanticToolReceipt(call: { tool: string; callId: string; arguments: unknown }, result: SemanticToolResult) {
const receipt = parseSemanticToolReceipt({ schema: SEMANTIC_RECEIPT_SCHEMA, operationId: call.tool,
callIdentitySha256: hash(call.callId), inputSha256: semanticInputSha256(call.arguments),
resultSha256: resultDigest(result), outcome: result.isError ? "error" : "returned" });
if (!receipt) throw new Error("Invalid semantic receipt identity");
return { receipt, result: { ...result, content: [...result.content, { type: "text" as const, text: JSON.stringify(receipt) }] } };
}
/**
* A native echo is only a candidate. Authority comes from the invocation callback.
* The 256 KiB bound applies to the WHOLE native rawOutput, including repeated
* content/detailedContent. Larger results still receive bridge receipts, but
* intentionally provide no native correlation evidence; never truncate to match.
*/
export function readNativeSemanticReceipt(raw: unknown): SemanticToolReceipt | null {
try {
if (!object(raw) || Buffer.byteLength(JSON.stringify(raw)) > MAX_NATIVE_BYTES || !Array.isArray(raw.contents)
|| raw.contents.length < 2 || raw.contents.length > 128) return null;
const blocks = raw.contents;
if (!blocks.every(b => object(b) && Object.keys(b).sort().join(",") === "text,type" && b.type === "text" && typeof b.text === "string")) return null;
const last = blocks.at(-1) as { text: string };
if (Buffer.byteLength(last.text) > MAX_SEMANTIC_RECEIPT_BYTES) return null;
const receipt = parseSemanticToolReceipt(JSON.parse(last.text));
if (!receipt) return null;
const original = blocks.slice(0, -1) as SemanticToolResult["content"];
if (original.some(b => { try { return parseSemanticToolReceipt(JSON.parse(b.text)) !== null; } catch { return false; } })) return null;
return resultDigest({ content: original, isError: receipt.outcome === "error" }) === receipt.resultSha256 ? receipt : null;
} catch { return null; }
}
export const sameSemanticReceipt = (a: SemanticToolReceipt, b: SemanticToolReceipt): boolean => semanticCanonicalJson(a) === semanticCanonicalJson(b);
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { heartbeatRuns, issues, issueThreadInteractions, issueApprovals, type Db } from "@paperclipai/db";
import type { PrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js";
import { nativeCompletionFeedback } from "./native-completion-feedback.js";
const state = vi.hoisted(() => ({ blockers: 0 }));
vi.mock("./native-deliverable-feedback.js", () => ({ validateNativeDeliverableEvidence: vi.fn(async () => undefined) }));
vi.mock("./automatic-completion-reviews.js", () => ({ findAutomaticCompletionReviews: vi.fn(async () => []) }));
vi.mock("../agent-invokability.js", () => ({ evaluateAgentInvokabilityFromDb: vi.fn() }));
vi.mock("../issues.js", () => ({ issueService: () => ({ getDependencyReadiness: async () => ({ unresolvedBlockerCount: state.blockers }) }) }));
vi.mock("./native-review-participant.js", () => ({ readNativeReviewAssignmentContext: () => null, getNativeReviewAssignment: vi.fn() }));
vi.mock("../../vendor/paperclip-runner/index.js", () => ({ normalizePrpResultSignals: () => ({ verification: [], actionableAttentionRequests: [] }) }));
const result = { reportedWorkDisposition: "done", summary: "EXACT_PRIVATE_MARKER", completionClaim: { objectiveSatisfied: true, criteria: [], remainingWork: [] } } as unknown as PrpStructuredRunResult;
function database(options: { interaction?: boolean; approval?: boolean; review?: boolean } = {}): Db {
// Pure query double: no client, PostgreSQL process, or network is created.
return { select: () => ({ from: (table: unknown) => {
const rows = table === heartbeatRuns ? [{ id: "run", companyId: "company", nativeIssueId: "issue", contextSnapshot: {} }]
: table === issues ? [{ id: "issue", companyId: "company", status: "in_progress", ...(options.review ? { executionState: { status: "pending" } } : {}) }]
: table === issueThreadInteractions && options.interaction ? [{ id: "request", kind: "request_confirmation", title: "Decision" }]
: table === issueApprovals && options.approval ? [{ id: "approval" }] : [];
const query = { where: () => query, limit: () => query, innerJoin: () => query, then: (resolve: (value: unknown[]) => unknown) => Promise.resolve(rows).then(resolve) };
return query;
} }) } as unknown as Db;
}
beforeEach(() => { state.blockers = 0; });
describe("native completion feedback format precedence", () => {
it("honors requested final format only after normal completion acceptance without echoing the summary", async () => {
const feedback = await nativeCompletionFeedback(database(), "run", result);
expect(feedback).toContain("Follow the user's explicitly requested final-response format, including an exact response when requested.");
expect(feedback).toContain("Task status will be committed after this turn and workspace finalization finish.");
expect(feedback).toContain("Do not claim an approval is needed unless one was requested.");
expect(feedback).not.toContain(result.summary);
});
it.each(["interaction", "approval", "review", "dependency"] as const)("preserves %s governance feedback instead of granting format precedence", async mode => {
state.blockers = mode === "dependency" ? 1 : 0;
const feedback = await nativeCompletionFeedback(database({ interaction: mode === "interaction", approval: mode === "approval", review: mode === "review" }), "run", result);
expect(feedback).not.toContain("final-response format");
expect(feedback).toMatch(/still waiting|still pending|unresolved dependencies/);
});
});
@@ -183,5 +183,5 @@ export async function nativeCompletionFeedback(
throw new Error("The named reviewer is not available in this company. Choose an available reviewer or report the concrete blocker.");
}
}
return "Completion report accepted. Task status will be committed after this turn and workspace finalization finish. Describe the completed work and any explicitly requested reviewer action; do not claim an approval is needed unless one was requested.";
return "Completion report accepted. Task status will be committed after this turn and workspace finalization finish. Follow the user's explicitly requested final-response format, including an exact response when requested. Otherwise describe the completed work and any explicitly requested reviewer action. Do not claim an approval is needed unless one was requested.";
}