mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 14:10:50 +02:00
fix(runner): bind normalized semantic receipt inputs
This commit is contained in:
1 parent
1d5e263af4
commit
ffdb1bce20
23 files changed
+498
-39
No files matched your search
@@ -1,6 +1,26 @@
|
||||
# Copilot 1.0.88 rich ACP capability audit
|
||||
|
||||
Current source candidate (2026-10-01): **Copilot profile v10 is unqualified**.
|
||||
Current source candidate (2026-10-01): **Copilot profile v11 is unqualified**.
|
||||
The authenticated Daytona v10 attempt exposed two independent receipt-boundary
|
||||
failures. Generic JWT redaction removed the public receipt schema name from a
|
||||
notice detail. Separately, a legitimate completion omitted optional fields that
|
||||
the strict result validator fills before forwarding; its original argument hash
|
||||
therefore differed from the proposed canonical result. The failed attempt remains
|
||||
failed even though its owned processes, sandbox and IPC retired cleanly.
|
||||
|
||||
Receipt v2 preserves the original `inputSha256` and separately records
|
||||
`normalizedInputSha256` at the same invocation's actual validated forwarding
|
||||
boundary. The capture commits only after that emission succeeds; absent or
|
||||
invalid capture remains null. Native result matching still requires the exact
|
||||
invocation-owned receipt and all original call/input/result hashes. Acceptance
|
||||
must independently match the normalized digest and the unique proposed/accepted
|
||||
result bodies. Transport return alone never proves acceptance. The server
|
||||
preserves only the fixed schema literal inside a validated receipt notice;
|
||||
adjacent credentials and JWT-shaped values remain redacted. Retained v10 warm
|
||||
sessions are incompatible. Native executable bytes are unchanged; new runtime
|
||||
packs and fresh local/Daytona qualification are required.
|
||||
|
||||
Historical source candidate (2026-10-01): **Copilot profile v10 was unqualified**.
|
||||
Admitted Paperclip MCP calls append a bounded `paperclip.semantic_tool_receipt.v1`
|
||||
text block after the original result blocks. It records the operation, call-ID
|
||||
hash, canonical argument hash, result hash and transport outcome. An invocation
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
# Rich ACP integration and qualification report
|
||||
|
||||
Current source checkpoint (2026-10-01): **Cursor v10, Copilot v10 and Pi v10
|
||||
Current source checkpoint (2026-10-01): **Cursor v10, Copilot v11 and Pi v10
|
||||
remain unqualified**. Copilot receipt v2 distinguishes original provider arguments
|
||||
from the strictly validated input actually forwarded for completion. A same-call
|
||||
capture binds both hashes without reconstructing omitted defaults in the grader.
|
||||
The server's JWT heuristic now preserves only the fixed schema literal in a
|
||||
validated semantic-receipt notice. Native call/result correlation and independent
|
||||
canonical acceptance remain required. The failed v10 Daytona attempt is retained;
|
||||
fresh v11 runtime packaging and local/Daytona evidence are still required.
|
||||
The [Copilot inventory](runner-copilot-capabilities.md) records both boundaries.
|
||||
|
||||
Historical source checkpoint (2026-10-01): **Cursor v10, Copilot v10 and Pi v10
|
||||
remain unqualified**. Cursor now shares the bounded native tool-ID mapping
|
||||
across sidecar activity and permissions, then deterministically joins that key
|
||||
to the canonical opaque execution ID. Copilot correlates native tool results with
|
||||
|
||||
@@ -223,7 +223,7 @@ impl AcpxProviderDescriptor {
|
||||
"1.0.88",
|
||||
None,
|
||||
None,
|
||||
"sha256:c6741d7e49cd1af4ecad9e07181ef96305bde48b56dce2958547f2f066666231",
|
||||
"sha256:e3057abcc2e6eb0db3a5c683a15e32159ac3caa78ba3d4c2a5d9221929e867dd",
|
||||
),
|
||||
"grok" => (
|
||||
"grok-4.7",
|
||||
@@ -2812,7 +2812,7 @@ mod tests {
|
||||
"copilot",
|
||||
"@github/copilot",
|
||||
"1.0.88",
|
||||
"sha256:c6741d7e49cd1af4ecad9e07181ef96305bde48b56dce2958547f2f066666231",
|
||||
"sha256:e3057abcc2e6eb0db3a5c683a15e32159ac3caa78ba3d4c2a5d9221929e867dd",
|
||||
None,
|
||||
None,
|
||||
"explicit-model",
|
||||
@@ -2914,6 +2914,13 @@ mod tests {
|
||||
assert!(wrong_agent.validate(&context()).is_err());
|
||||
}
|
||||
if agent == "copilot" {
|
||||
let mut previous_v10 = value.clone();
|
||||
previous_v10["commandDigest"] = json!(
|
||||
"sha256:c6741d7e49cd1af4ecad9e07181ef96305bde48b56dce2958547f2f066666231"
|
||||
);
|
||||
let previous_v10: AcpxProviderDescriptor =
|
||||
serde_json::from_value(previous_v10).unwrap();
|
||||
assert!(previous_v10.validate(&context()).is_err());
|
||||
let mut previous_v6 = value.clone();
|
||||
previous_v6["commandDigest"] = json!(
|
||||
"sha256:0fe49c2f8a2b144344d0de745fed1e4568df305de3a26c3a121dec21c8d4b751"
|
||||
|
||||
@@ -7,7 +7,7 @@ import { COPILOT_VERSION, materializePinnedCopilotBinary, resolveCopilotDistribu
|
||||
|
||||
const MAX_ARCHIVE_BYTES = 128 * 1024 * 1024;
|
||||
const MAX_EXPANDED_BYTES = 384 * 1024 * 1024;
|
||||
const PROFILE_DIGEST = "sha256:c6741d7e49cd1af4ecad9e07181ef96305bde48b56dce2958547f2f066666231";
|
||||
const PROFILE_DIGEST = "sha256:e3057abcc2e6eb0db3a5c683a15e32159ac3caa78ba3d4c2a5d9221929e867dd";
|
||||
|
||||
/** Build-time only; outputRoot is the exact runner-owned platform asset directory. */
|
||||
export async function buildPinnedCopilotDistribution({ outputRoot, platform = process.platform, architecture = process.arch }, { fetchImpl = fetch } = {}) {
|
||||
|
||||
@@ -562,6 +562,7 @@ describe("native backend factory", () => {
|
||||
["cursor", "../../test/fixtures/cursor-acp/profile-v9-identity.json"],
|
||||
["copilot", "../../test/fixtures/copilot-profile-v7-identity.json"],
|
||||
["copilot", "../../test/fixtures/copilot-profile-v9-identity.json"],
|
||||
["copilot", "../../test/fixtures/copilot-profile-v10-identity.json"],
|
||||
["pi", "../../test-fixtures/pi-acp/profile-v9-identity.json"],
|
||||
] as const)("rejects the exact historical %s identity before runtime startup", (agent, path) => {
|
||||
const historical = JSON.parse(readFileSync(new URL(path, import.meta.url), "utf8"));
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { appendSemanticToolReceipt } from "../drivers/semantic-tool-receipt.js";
|
||||
import { appendSemanticToolReceipt, readNativeSemanticReceipt, semanticInputSha256 } from "../drivers/semantic-tool-receipt.js";
|
||||
import { startRunnerToolBridge, type RunnerToolCall } from "../drivers/runner-tool-bridge.js";
|
||||
import { validatePrpStructuredRunResult } from "../protocol/replay-contract.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";
|
||||
@@ -225,11 +227,59 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
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" } } });
|
||||
expect(events[0]).toMatchObject({ payload: { category: "paperclip_semantic_tool_receipt_v2", 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.each(["forwarded", "emit-failed"])("binds native v2 receipts to actual sidecar-normalized input: %s", async mode => {
|
||||
const raw = { reportedWorkDisposition: "done", summary: "Complete", evidence: [], verification: [],
|
||||
completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [], remainingWork: [] } };
|
||||
const validated = validatePrpStructuredRunResult(raw);
|
||||
if (!validated.ok) throw new Error("Invalid fixture");
|
||||
const tools = new Map<string, any>(), emitted: any[] = [], notices: any[] = [];
|
||||
const projector = createCopilotToolEvidence({ sessionId: "session", turnId: "test-turn", workingDirectory: "/workspace",
|
||||
active: () => true, emit: event => { validateAcpxRichEvent(event); notices.push(event); } });
|
||||
const handler = loadWaitForTool({ tools, emitted, validate: validatePrpStructuredRunResult,
|
||||
...(mode === "emit-failed" ? { emit: () => { throw new Error("fixture emission failed"); } } : {}) });
|
||||
const bridge = await startRunnerToolBridge({ handler, captureSemanticReceipt: () => projector.captureSemanticReceipt() });
|
||||
try {
|
||||
projector.tool({ type: "tool_call", tag: "tool_call", toolCallId: "native-call", kind: "other", status: "pending", rawInput: raw });
|
||||
const response = fetch(bridge.url, { method: "POST", headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "paperclip_finish", arguments: raw } }) });
|
||||
if (mode === "forwarded") {
|
||||
await vi.waitFor(() => expect(tools.has("3")).toBe(true));
|
||||
expect(emitted).toEqual([{ callId: "3", operationId: "paperclip_finish", input: validated.result }]);
|
||||
// The receipt hashes the actual emitted input, not a second normalization.
|
||||
tools.get("3").settle({ accepted: true });
|
||||
}
|
||||
const body = await (await response).json();
|
||||
const receipt = readNativeSemanticReceipt({ contents: body.result.content });
|
||||
expect(receipt).toMatchObject({ schema: "paperclip.semantic_tool_receipt.v2", inputSha256: semanticInputSha256(raw),
|
||||
normalizedInputSha256: mode === "forwarded" ? semanticInputSha256(emitted[0].input) : null,
|
||||
outcome: mode === "forwarded" ? "returned" : "error" });
|
||||
expect(semanticInputSha256(raw)).not.toBe(semanticInputSha256(validated.result));
|
||||
projector.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "native-call", status: mode === "forwarded" ? "completed" : "failed",
|
||||
rawOutput: { contents: body.result.content } });
|
||||
const details = Object.fromEntries(notices.at(-1).payload.details.map((d: any) => [d.name, d.value]));
|
||||
expect(details).toMatchObject({ semanticInputSha256: semanticInputSha256(raw),
|
||||
semanticNormalizedInputSha256: mode === "forwarded" ? semanticInputSha256(emitted[0].input) : "null" });
|
||||
expect(notices.find(event => event.payload.category === "paperclip_semantic_tool_receipt_v2").payload.details).toHaveLength(8);
|
||||
} finally {
|
||||
for (const pending of tools.values()) pending.cleanup();
|
||||
await bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not capture normalized authority when actual sidecar validation fails", async () => {
|
||||
const emitted: unknown[] = [], capture = vi.fn(() => vi.fn());
|
||||
const wait = loadWaitForTool({ tools: new Map(), emitted, validate: validatePrpStructuredRunResult });
|
||||
await expect(wait({ tool: "paperclip_finish", callId: "bad", arguments: {}, signal: new AbortController().signal,
|
||||
captureNormalizedInput: capture })).rejects.toThrow("ACPX semantic result failed PRP schema validation");
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
expect(emitted).toEqual([]);
|
||||
});
|
||||
|
||||
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"));
|
||||
@@ -1011,7 +1061,9 @@ class SidecarProcess {
|
||||
function loadWaitForTool(input: {
|
||||
tools: Map<string, unknown>;
|
||||
emitted: unknown[];
|
||||
}): (call: { callId: string; tool: string; arguments: Record<string, unknown>; signal: AbortSignal }) => Promise<unknown> {
|
||||
validate?: typeof validatePrpStructuredRunResult;
|
||||
emit?: () => void;
|
||||
}): (call: RunnerToolCall) => Promise<unknown> {
|
||||
const source = readFileSync(
|
||||
fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)),
|
||||
"utf8",
|
||||
@@ -1034,13 +1086,13 @@ function loadWaitForTool(input: {
|
||||
(value: string) => value,
|
||||
input.tools,
|
||||
"test-turn",
|
||||
(_eventType: string, payload: unknown) => input.emitted.push(payload),
|
||||
(_eventType: string, payload: unknown) => { input.emit?.(); input.emitted.push(payload); },
|
||||
"paperclip_finish",
|
||||
"paperclip_block",
|
||||
(argumentsValue: unknown) => ({
|
||||
input.validate ?? ((argumentsValue: unknown) => ({
|
||||
ok: true,
|
||||
result: argumentsValue,
|
||||
}),
|
||||
})),
|
||||
(value: unknown) => value,
|
||||
(value: unknown) => value,
|
||||
512,
|
||||
|
||||
@@ -717,6 +717,7 @@ async function waitForTool(call: RunnerToolCall): Promise<unknown> {
|
||||
// This is the same roundtrip used by ordinary dynamic tools; emitting a
|
||||
// local semantic_result here would let an invalid review handoff appear
|
||||
// accepted before the server has checked it.
|
||||
const commitNormalizedInput = call.captureNormalizedInput?.(validation.result);
|
||||
emit(
|
||||
"runtime.tool_called",
|
||||
{
|
||||
@@ -726,6 +727,7 @@ async function waitForTool(call: RunnerToolCall): Promise<unknown> {
|
||||
},
|
||||
activeTurnId,
|
||||
);
|
||||
commitNormalizedInput?.();
|
||||
return await new Promise((settle, reject) => {
|
||||
const abort = () => {
|
||||
const pending = tools.get(callId);
|
||||
|
||||
@@ -296,7 +296,7 @@ describe("NativeExecutionInputV1", () => {
|
||||
})).toThrow("eventExpiryDays");
|
||||
});
|
||||
|
||||
it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => {
|
||||
it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => {
|
||||
const provider = {
|
||||
kind: "acpx",
|
||||
agent: "pi",
|
||||
@@ -328,7 +328,7 @@ describe("NativeExecutionInputV1", () => {
|
||||
profile: provider.profile,
|
||||
});
|
||||
expect(parseNativeExecutionInput(parsed)).toEqual(parsed);
|
||||
for (const unsupportedVersion of [0, 11, 1.5, "10", null]) {
|
||||
for (const unsupportedVersion of [0, 12, 1.5, "11", null]) {
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...input,
|
||||
session: { ...input.session, driverKind: "acpx_runtime" },
|
||||
|
||||
@@ -91,7 +91,7 @@ export interface NativeAcpxProfileSnapshot {
|
||||
protocolVersion: 1;
|
||||
acpxVersion: "0.13.1";
|
||||
agent: NativeAcpxAgent;
|
||||
agentProfileVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
|
||||
agentProfileVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11;
|
||||
agentServerPackage: string;
|
||||
agentServerVersion: string;
|
||||
agentRuntimePackage: string | null;
|
||||
@@ -608,7 +608,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
|| profile.protocolVersion !== 1
|
||||
|| profile.acpxVersion !== "0.13.1"
|
||||
|| profile.agent !== provider.agent
|
||||
|| (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3 && profile.agentProfileVersion !== 4 && profile.agentProfileVersion !== 5 && profile.agentProfileVersion !== 6 && profile.agentProfileVersion !== 7 && profile.agentProfileVersion !== 8 && profile.agentProfileVersion !== 9 && profile.agentProfileVersion !== 10)
|
||||
|| (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3 && profile.agentProfileVersion !== 4 && profile.agentProfileVersion !== 5 && profile.agentProfileVersion !== 6 && profile.agentProfileVersion !== 7 && profile.agentProfileVersion !== 8 && profile.agentProfileVersion !== 9 && profile.agentProfileVersion !== 10 && profile.agentProfileVersion !== 11)
|
||||
) {
|
||||
throw new NativeExecutionInputError("input.provider.profile does not match the qualified ACPX v1 profile");
|
||||
}
|
||||
|
||||
@@ -26,6 +26,48 @@ import type {
|
||||
import type { AcpxRecoveryWorkspaceLease } from "./runtime-sandbox.js";
|
||||
|
||||
describe("Codex ACPX harness driver", () => {
|
||||
it("captures the direct driver's normalized input only when the exact proposal is retained", async () => {
|
||||
const fixture = driverFixture({ agent: "copilot", model: "explicit-test-model", providerPolicy: { readOnly: true } }, { runtimeEvents: [] });
|
||||
const session = await fixture.driver.openSession({ runId: "run-normalized-receipt", normalizedSessionId: "session-1", workingDirectory: "/workspace" });
|
||||
await session.startTurn({ message: { text: "Complete" } });
|
||||
const { schema: _, attentionRequests: __, artifacts: ___, ...raw } = completedResult();
|
||||
const commit = vi.fn(), capture = vi.fn((value: unknown) => { expect(commit).not.toHaveBeenCalled(); return commit; });
|
||||
const handler = fixture.hostOptions!.semanticTools!.handler;
|
||||
await expect(handler({ tool: PRP_COMPLETION_TOOL_NAME, callId: "finish", arguments: raw, signal: new AbortController().signal,
|
||||
captureNormalizedInput: capture })).resolves.toMatchObject({ accepted: true });
|
||||
expect(capture).toHaveBeenCalledExactlyOnceWith(completedResult());
|
||||
expect(commit).toHaveBeenCalledOnce();
|
||||
const repeatedCapture = vi.fn(() => vi.fn());
|
||||
await expect(handler({ tool: PRP_COMPLETION_TOOL_NAME, callId: "different-call-same-result", arguments: raw, signal: new AbortController().signal,
|
||||
captureNormalizedInput: repeatedCapture })).resolves.toMatchObject({ accepted: true });
|
||||
expect(repeatedCapture).not.toHaveBeenCalled();
|
||||
const invalidCapture = vi.fn(() => vi.fn());
|
||||
await expect(handler({ tool: PRP_COMPLETION_TOOL_NAME, callId: "invalid", arguments: {}, signal: new AbortController().signal,
|
||||
captureNormalizedInput: invalidCapture })).rejects.toThrow("Invalid semantic run result");
|
||||
expect(invalidCapture).not.toHaveBeenCalled();
|
||||
fixture.finishTurn({ status: "completed" });
|
||||
const events = await collectUntil(session.events(), "turn.completed");
|
||||
expect(events.find(event => event.eventType === "run.result.proposed")?.payload).toEqual(capture.mock.calls[0]![0]);
|
||||
await session.close({ reason: "same-invocation normalization verified" });
|
||||
});
|
||||
it("does not commit a direct normalized input digest when proposal retention is backpressured", async () => {
|
||||
const fixture = driverFixture({ agent: "copilot", model: "explicit-test-model", providerPolicy: { readOnly: true } }, {
|
||||
maxBufferedEvents: 4, terminalEventReserve: 0,
|
||||
runtimeEvents: Array.from({ length: 8 }, (_, n) => ({ type: "text_delta" as const, stream: "output" as const, text: `bounded-${n}` })),
|
||||
});
|
||||
const session = await fixture.driver.openSession({ runId: "run-normalized-pressure", normalizedSessionId: "session-1", workingDirectory: "/workspace" });
|
||||
await session.startTurn({ message: { text: "Complete" } });
|
||||
await vi.waitFor(async () => expect((await session.transcript!()).eventCount).toBeGreaterThanOrEqual(4));
|
||||
const commit = vi.fn(), capture = vi.fn(() => commit);
|
||||
await expect(fixture.hostOptions!.semanticTools!.handler({ tool: PRP_COMPLETION_TOOL_NAME, callId: "finish", arguments: completedResult(),
|
||||
signal: new AbortController().signal, captureNormalizedInput: capture })).rejects.toThrow("event consumer must drain");
|
||||
expect(capture).toHaveBeenCalledOnce();
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
fixture.finishTurn({ status: "completed" });
|
||||
const events = await collectUntil(session.events(), "turn.completed");
|
||||
expect(events.some(event => event.eventType === "run.result.proposed")).toBe(false);
|
||||
await session.close({ reason: "unretained input has no digest" });
|
||||
});
|
||||
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" });
|
||||
@@ -37,17 +79,17 @@ describe("Codex ACPX harness driver", () => {
|
||||
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");
|
||||
const receipts = firstEvents.filter(e => e.eventType === "provider.notice.recorded" && e.payload.category === "paperclip_semantic_tool_receipt_v2");
|
||||
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);
|
||||
expect(transcript.events.filter((e: PrpEvent) => e.payload.category === "paperclip_semantic_tool_receipt_v2")).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([]);
|
||||
expect(secondEvents.filter(e => e.payload.category === "paperclip_semantic_tool_receipt_v2")).toEqual([]);
|
||||
await session.close({ reason: "receipt turn scope verified" });
|
||||
});
|
||||
|
||||
|
||||
@@ -1185,6 +1185,7 @@ class CodexAcpxSession implements HarnessSession {
|
||||
this.#semanticFingerprint === null ||
|
||||
(claimsLaterTurn && !repeatsPendingTransfer)
|
||||
) {
|
||||
const commitNormalizedInput = call.captureNormalizedInput?.(validation.result);
|
||||
if (
|
||||
!this.#emit("run.result.proposed", validation.result, {
|
||||
turnId,
|
||||
@@ -1196,6 +1197,7 @@ class CodexAcpxSession implements HarnessSession {
|
||||
"the event consumer must drain provider events before a semantic result can be accepted",
|
||||
);
|
||||
}
|
||||
commitNormalizedInput?.();
|
||||
if (claimsLaterTurn) {
|
||||
// A reaffirming retry does not own the durable result until its
|
||||
// provider turn completes successfully. A failed or interrupted
|
||||
|
||||
@@ -23,6 +23,13 @@ const SEMANTIC_RECEIPT_SOURCES = [
|
||||
["../semantic-tool-receipt.ts", "semanticReceiptSourceSha256"],
|
||||
["../runner-tool-bridge.ts", "semanticBridgeSourceSha256"],
|
||||
["copilot-tool-evidence.ts", "toolEvidenceSourceSha256"],
|
||||
["../../cli/acpx-runtime-sidecar.ts", "semanticSidecarSourceSha256"],
|
||||
["codex-acpx-driver.ts", "semanticDirectDriverSourceSha256"],
|
||||
["../../protocol/replay-contract.ts", "semanticValidationSourceSha256"],
|
||||
["../../protocol/result-normalization.ts", "semanticNormalizationSourceSha256"],
|
||||
["../../contracts/completion-result.ts", "semanticCompletionContractSourceSha256"],
|
||||
["../../protocol/generated/standalone-validators.ts", "semanticValidatorsSourceSha256"],
|
||||
["../../protocol/generated/schema-bundle.ts", "semanticSchemaBundleSourceSha256"],
|
||||
] as const;
|
||||
|
||||
// Walk executable imports, not just the projector's immediate dependencies.
|
||||
@@ -51,12 +58,13 @@ async function permissionPolicyClosure(extraClassifierImport = false): Promise<S
|
||||
vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi.fn() }));
|
||||
|
||||
describe("Copilot build-owned installation", () => {
|
||||
it("admits the current v10 declaration and binds its source policy, receipts and patch hashes", () => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v10-identity.json", import.meta.url), "utf8"));
|
||||
it("admits the current v11 declaration and binds its source policy, receipts and patch hashes", () => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v11-identity.json", import.meta.url), "utf8"));
|
||||
expect(identity.declaration.systemInstructionDelivery).toBe(COPILOT_SYSTEM_INSTRUCTION_DELIVERY);
|
||||
expect(identity.declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1");
|
||||
expect(identity.declaration.permissionContextContract).toBe(COPILOT_PERMISSION_CONTEXT_CONTRACT);
|
||||
expect(identity.declaration.semanticToolReceiptContract).toBe("paperclip.semantic_tool_receipt.v1");
|
||||
expect(identity.declaration.semanticToolReceiptContract).toBe("paperclip.semantic_tool_receipt.v2");
|
||||
expect(identity.declaration.semanticNormalizedInputContract).toBe("validated-forwarded-input-commit-v1");
|
||||
expect(identity.declaration.messageIdentityContract).toBe("copilot-native-message-id-v1");
|
||||
expect(identity.declaration.ownedDistributionDelivery).toBe("COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1");
|
||||
const inventory = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-message-identity-distributions-1.0.88.json", import.meta.url), "utf8"));
|
||||
@@ -81,7 +89,7 @@ describe("Copilot build-owned installation", () => {
|
||||
expect([...closure].filter(path => !bound.has(path))).toEqual(["negative-control:unbound-policy"]);
|
||||
});
|
||||
it.each([...PERMISSION_POLICY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES])("changing %s changes the candidate identity", (_path, field) => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v10-identity.json", import.meta.url), "utf8"));
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v11-identity.json", import.meta.url), "utf8"));
|
||||
identity.declaration[field] = "0".repeat(64);
|
||||
const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b)));
|
||||
expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).not.toBe(identity.commandDigest);
|
||||
@@ -122,7 +130,7 @@ describe("Copilot build-owned installation", () => {
|
||||
});
|
||||
it("rejects changed profile identities before native file access", async () => {
|
||||
vi.mocked(verifyNativeAcpxInstallation).mockClear();
|
||||
for (const override of [{ agentServerVersion: "latest" }, { commandDigest: "sha256:untrusted" }, { commandDigest: "sha256:ed39259990cb0efda48d6105d2fa3599aac76873eb75cd80b4d0b179ce0579a6" }, { agent: "cursor" }, { agentProfileVersion: 1 }, { agentProfileVersion: 2 }, { agentProfileVersion: 3 }, { agentProfileVersion: 4 }, { agentProfileVersion: 5 }, { agentProfileVersion: 6 }, { agentProfileVersion: 7 }, { agentProfileVersion: 8 }, { agentProfileVersion: 9 }]) {
|
||||
for (const override of [{ agentServerVersion: "latest" }, { commandDigest: "sha256:untrusted" }, { commandDigest: "sha256:ed39259990cb0efda48d6105d2fa3599aac76873eb75cd80b4d0b179ce0579a6" }, { agent: "cursor" }, { agentProfileVersion: 1 }, { agentProfileVersion: 2 }, { agentProfileVersion: 3 }, { agentProfileVersion: 4 }, { agentProfileVersion: 5 }, { agentProfileVersion: 6 }, { agentProfileVersion: 7 }, { agentProfileVersion: 8 }, { agentProfileVersion: 9 }, { agentProfileVersion: 10 }]) {
|
||||
await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, ...override } as never)).rejects.toThrow("exact pinned");
|
||||
}
|
||||
expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled();
|
||||
|
||||
@@ -132,13 +132,13 @@ describe("Copilot authoritative semantic receipt correlation", () => {
|
||||
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")!;
|
||||
const authoritative = h.events.find(e => e.payload.category === "paperclip_semantic_tool_receipt_v2")!;
|
||||
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 => {
|
||||
it.each(["untrusted", "different-input", "different-normalized-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));
|
||||
@@ -151,6 +151,7 @@ describe("Copilot authoritative semantic receipt correlation", () => {
|
||||
}
|
||||
const output = structuredClone(bound.result.content);
|
||||
if (scenario === "different-result") output[0]!.text = "changed";
|
||||
if (scenario === "different-normalized-input") output.at(-1)!.text = JSON.stringify({ ...bound.receipt, normalizedInputSha256: "a".repeat(64) });
|
||||
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" }));
|
||||
|
||||
@@ -7,7 +7,7 @@ import { safeCopilotEditTarget } from "./copilot-permission-context.js";
|
||||
|
||||
const LIMIT = 256;
|
||||
const CATEGORY = "copilot_tool_evidence_v1";
|
||||
type Fields = Record<string, string | number | boolean>;
|
||||
type Fields = Record<string, string | number | boolean | null>;
|
||||
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);
|
||||
@@ -153,6 +153,7 @@ export function createCopilotToolEvidence(binding: {
|
||||
fields.semanticOperationId = candidate.operationId;
|
||||
fields.semanticCallIdentitySha256 = candidate.callIdentitySha256;
|
||||
fields.semanticInputSha256 = candidate.inputSha256;
|
||||
if (candidate.schema === "paperclip.semantic_tool_receipt.v2") fields.semanticNormalizedInputSha256 = candidate.normalizedInputSha256;
|
||||
fields.semanticResultSha256 = candidate.resultSha256;
|
||||
fields.semanticOutcome = candidate.outcome;
|
||||
}
|
||||
@@ -190,7 +191,7 @@ export function createCopilotToolEvidence(binding: {
|
||||
return;
|
||||
}
|
||||
semanticReceipts.set(parsed.callIdentitySha256, parsed);
|
||||
notice("semantic_result", undefined, { ...parsed }, "paperclip/semantic_tool_result", "paperclip_semantic_tool_receipt_v1");
|
||||
notice("semantic_result", undefined, { ...parsed }, "paperclip/semantic_tool_result", parsed.schema === "paperclip.semantic_tool_receipt.v2" ? "paperclip_semantic_tool_receipt_v2" : "paperclip_semantic_tool_receipt_v1");
|
||||
}); };
|
||||
},
|
||||
tool: (event: unknown) => { safely(() => projection.tool(event)); },
|
||||
|
||||
@@ -72,10 +72,10 @@ export const QUALIFIED_ACPX_PROFILES: Readonly<
|
||||
},
|
||||
copilot: {
|
||||
driverKind: ACPX_DRIVER_KIND, protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION,
|
||||
acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 10,
|
||||
acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 11,
|
||||
agentServerPackage: "@github/copilot", agentServerVersion: "1.0.88",
|
||||
agentRuntimePackage: null, agentRuntimeVersion: null,
|
||||
commandDigest: "sha256:c6741d7e49cd1af4ecad9e07181ef96305bde48b56dce2958547f2f066666231",
|
||||
commandDigest: "sha256:e3057abcc2e6eb0db3a5c683a15e32159ac3caa78ba3d4c2a5d9221929e867dd",
|
||||
// Authenticated discovery has not established a qualification model. Never
|
||||
// turn this empty declaration into a default; callers must select an ID.
|
||||
qualificationModel: "", reportedModelId: "", permissionPolicy: "interactive",
|
||||
|
||||
@@ -33,6 +33,7 @@ describe("ACPX recovery identity", () => {
|
||||
["copilot", "../../../test/fixtures/copilot-profile-v7-identity.json"],
|
||||
["copilot", "../../../test/fixtures/copilot-profile-v8-identity.json"],
|
||||
["copilot", "../../../test/fixtures/copilot-profile-v9-identity.json"],
|
||||
["copilot", "../../../test/fixtures/copilot-profile-v10-identity.json"],
|
||||
["pi", "../../../test-fixtures/pi-acp/profile-v9-identity.json"],
|
||||
] as const)("rejects retained %s sessions after the execution identity changes", async (agent, path) => {
|
||||
const fixture = await recoveryFixture();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readNativeSemanticReceipt, type SemanticToolReceipt, type SemanticToolResult } from "./semantic-tool-receipt.js";
|
||||
import { readNativeSemanticReceipt, semanticInputSha256, type SemanticToolReceipt, type SemanticToolResult } from "./semantic-tool-receipt.js";
|
||||
import { validatePrpStructuredRunResult } from "../protocol/replay-contract.js";
|
||||
import { connect } from "node:net";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -468,6 +469,71 @@ function tool(name: string): Readonly<Record<string, unknown>> {
|
||||
}
|
||||
|
||||
describe("Copilot semantic receipt opt-in", () => {
|
||||
const rawFinish = { reportedWorkDisposition: "done", summary: "The task is complete.", evidence: [], verification: [],
|
||||
completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [], remainingWork: [] } };
|
||||
it.each(["committed", "not-forwarded", "invalid", "duplicate-capture", "conflicting-capture", "duplicate-commit"])(
|
||||
"captures only the invocation's exact forwarded normalized input: %s", async mode => {
|
||||
const receipts: SemanticToolReceipt[] = [];
|
||||
let capturedInput: unknown;
|
||||
const bridge = await startRunnerToolBridge({ captureSemanticReceipt: () => receipt => receipts.push(receipt), handler: async call => {
|
||||
const validation = validatePrpStructuredRunResult(call.arguments);
|
||||
if (!validation.ok) throw new Error("Invalid fixture input");
|
||||
capturedInput = structuredClone(validation.result);
|
||||
const invalid: Record<string, unknown> = {}; invalid.self = invalid;
|
||||
const commit = call.captureNormalizedInput!(mode === "invalid" ? invalid : validation.result);
|
||||
if (mode === "duplicate-capture") call.captureNormalizedInput!(validation.result)();
|
||||
if (mode === "conflicting-capture") call.captureNormalizedInput!({ ...validation.result, summary: "foreign" })();
|
||||
if (mode !== "not-forwarded") commit();
|
||||
if (mode === "duplicate-commit") commit();
|
||||
// The digest is a snapshot of forwarded input, not a later mutable value.
|
||||
validation.result.summary = "later mutation";
|
||||
return { accepted: true };
|
||||
} });
|
||||
bridges.push(bridge);
|
||||
const request = { id: 3, method: "tools/call", params: { name: "paperclip_finish", arguments: rawFinish } };
|
||||
const body = await (await rpc(bridge, request)).json();
|
||||
expect(receipts).toHaveLength(1);
|
||||
expect(readNativeSemanticReceipt({ contents: body.result.content })).toEqual(receipts[0]);
|
||||
expect(receipts[0]).toMatchObject({ schema: "paperclip.semantic_tool_receipt.v2", inputSha256: semanticInputSha256(rawFinish),
|
||||
normalizedInputSha256: mode === "committed" ? semanticInputSha256(capturedInput) : null });
|
||||
expect(semanticInputSha256(rawFinish)).not.toBe(semanticInputSha256(capturedInput));
|
||||
expect(await (await rpc(bridge, request)).json()).toEqual(body);
|
||||
expect(receipts).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
it("keeps concurrent call captures separate and ignores captures after settlement", async () => {
|
||||
const pending = new Map<string, { capture: NonNullable<import("./runner-tool-bridge.js").RunnerToolCall["captureNormalizedInput"]>; settle: () => void }>();
|
||||
const receipts: SemanticToolReceipt[] = [];
|
||||
const bridge = await startRunnerToolBridge({ captureSemanticReceipt: () => receipt => receipts.push(receipt), handler: call => new Promise(resolve => {
|
||||
pending.set(call.callId, { capture: call.captureNormalizedInput!, settle: () => resolve({ accepted: true }) });
|
||||
}) });
|
||||
bridges.push(bridge);
|
||||
const first = rpc(bridge, { id: "first", method: "tools/call", params: { name: "paperclip_finish", arguments: rawFinish } });
|
||||
const secondInput = { ...rawFinish, summary: "Second call" };
|
||||
const second = rpc(bridge, { id: "second", method: "tools/call", params: { name: "paperclip_finish", arguments: secondInput } });
|
||||
await vi.waitFor(() => expect(pending.size).toBe(2));
|
||||
const normalizedFirst = validatePrpStructuredRunResult(rawFinish); const normalizedSecond = validatePrpStructuredRunResult(secondInput);
|
||||
if (!normalizedFirst.ok || !normalizedSecond.ok) throw new Error("Invalid fixture");
|
||||
pending.get("second")!.capture(normalizedSecond.result)(); pending.get("second")!.settle();
|
||||
const secondReceipt = readNativeSemanticReceipt({ contents: (await (await second).json()).result.content });
|
||||
expect(secondReceipt).toMatchObject({ inputSha256: semanticInputSha256(secondInput), normalizedInputSha256: semanticInputSha256(normalizedSecond.result) });
|
||||
pending.get("second")!.capture(normalizedFirst.result)();
|
||||
pending.get("first")!.capture(normalizedFirst.result)(); pending.get("first")!.settle();
|
||||
const firstReceipt = readNativeSemanticReceipt({ contents: (await (await first).json()).result.content });
|
||||
expect(firstReceipt).toMatchObject({ inputSha256: semanticInputSha256(rawFinish), normalizedInputSha256: semanticInputSha256(normalizedFirst.result) });
|
||||
expect(receipts).toEqual([secondReceipt, firstReceipt]);
|
||||
});
|
||||
it("does not accept model metadata as normalized authority or widen non-Copilot calls", async () => {
|
||||
const modelMetadata = { normalizedInputSha256: "a".repeat(64), captureNormalizedInput: "forged" };
|
||||
const withEvidence = await startRunnerToolBridge({ tools: [tool("documents.read")], captureSemanticReceipt: () => () => {}, handler: async call => {
|
||||
expect(call.captureNormalizedInput).toBeUndefined(); return modelMetadata;
|
||||
} }); bridges.push(withEvidence);
|
||||
const body = await (await rpc(withEvidence, { id: 1, method: "tools/call", params: { name: "documents.read", arguments: modelMetadata } })).json();
|
||||
expect(readNativeSemanticReceipt({ contents: body.result.content })).toHaveProperty("normalizedInputSha256", null);
|
||||
const plain = await startRunnerToolBridge({ handler: async call => { expect(call.captureNormalizedInput).toBeUndefined(); return { accepted: true }; } }); bridges.push(plain);
|
||||
const plainBody = await (await rpc(plain, { id: 2, method: "tools/call", params: { name: "paperclip_finish", arguments: rawFinish } })).json();
|
||||
expect(readNativeSemanticReceipt({ contents: plainBody.result.content })).toBeNull();
|
||||
});
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { appendSemanticToolReceipt, semanticCanonicalJson as canonicalJson, type SemanticToolReceipt } from "./semantic-tool-receipt.js";
|
||||
import { appendSemanticToolReceipt, semanticCanonicalJson as canonicalJson, semanticInputSha256, type SemanticToolReceipt } from "./semantic-tool-receipt.js";
|
||||
import {
|
||||
createServer,
|
||||
type IncomingMessage,
|
||||
@@ -27,6 +27,9 @@ export interface RunnerToolCall {
|
||||
callId: string;
|
||||
arguments: unknown;
|
||||
signal: AbortSignal;
|
||||
/** Internal, invocation-owned evidence. Snapshot before forwarding; commit only
|
||||
* after that exact normalized input is forwarded. Never supplied by the model. */
|
||||
captureNormalizedInput?: (input: unknown) => (() => void);
|
||||
}
|
||||
|
||||
export interface RunnerToolBridgeOptions {
|
||||
@@ -327,10 +330,32 @@ 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. */ } }
|
||||
let receiptSealed = false;
|
||||
let normalizationCaptured = false;
|
||||
let normalizationInvalid = false;
|
||||
let normalizedInputSha256: string | null = null;
|
||||
const captureNormalizedInput = (input: unknown): (() => void) => {
|
||||
if (receiptSealed || controller?.signal.aborted) return () => {};
|
||||
if (normalizationCaptured) { normalizationInvalid = true; return () => {}; }
|
||||
normalizationCaptured = true;
|
||||
let digest: string;
|
||||
try {
|
||||
if (!isRecord(input) || Buffer.byteLength(JSON.stringify(input)) > context.maxBodyBytes) throw new Error("Invalid normalized input");
|
||||
digest = semanticInputSha256(input);
|
||||
} catch { normalizationInvalid = true; return () => {}; }
|
||||
let committed = false;
|
||||
return () => {
|
||||
if (receiptSealed || controller?.signal.aborted) return;
|
||||
if (committed) { normalizationInvalid = true; return; }
|
||||
committed = true;
|
||||
if (!normalizationInvalid) normalizedInputSha256 = digest;
|
||||
};
|
||||
};
|
||||
const withReceipt = (result: RunnerToolCallResult): RunnerToolCallResult => {
|
||||
receiptSealed = true;
|
||||
if (!context.captureSemanticReceipt) return result;
|
||||
try {
|
||||
const bound = appendSemanticToolReceipt({ tool, callId, arguments: args }, result);
|
||||
const bound = appendSemanticToolReceipt({ tool, callId, arguments: args, normalizedInputSha256: normalizationInvalid ? null : normalizedInputSha256 }, result);
|
||||
try { observeReceipt?.(bound.receipt); } catch { /* Evidence cannot change the tool outcome. */ }
|
||||
return bound.result;
|
||||
} catch { return result; }
|
||||
@@ -345,6 +370,8 @@ async function handleRequest(
|
||||
callId,
|
||||
arguments: structuredClone(args),
|
||||
signal: controller!.signal,
|
||||
...(context.captureSemanticReceipt && (tool === PRP_COMPLETION_TOOL_NAME || tool === PRP_BLOCK_TOOL_NAME)
|
||||
? { captureNormalizedInput } : {}),
|
||||
}),
|
||||
)
|
||||
.then((result) => successfulToolResult(tool, callId, result)),
|
||||
|
||||
@@ -43,4 +43,20 @@ describe("bounded semantic receipt carrier", () => {
|
||||
expect(parseSemanticToolReceipt({ ...receipt, inputSha256: "a".repeat(65) })).toBeNull();
|
||||
expect(parseSemanticToolReceipt({ ...receipt, outcome: "accepted" })).toBeNull();
|
||||
});
|
||||
it("keeps raw and forwarded input identities separate and versions historical receipts honestly", () => {
|
||||
const normalizedInputSha256 = semanticInputSha256({ ...call.arguments, schema: "paperclip.run_result.v1" });
|
||||
const { receipt, result } = appendSemanticToolReceipt({ ...call, normalizedInputSha256 }, original);
|
||||
expect(receipt).toMatchObject({ schema: "paperclip.semantic_tool_receipt.v2", normalizedInputSha256,
|
||||
inputSha256: semanticInputSha256(call.arguments) });
|
||||
expect(receipt.inputSha256).not.toBe(normalizedInputSha256);
|
||||
expect(readNativeSemanticReceipt({ contents: result.content })).toEqual(receipt);
|
||||
expect(appendSemanticToolReceipt(call, original).receipt).toHaveProperty("normalizedInputSha256", null);
|
||||
const { normalizedInputSha256: _, ...legacyFields } = receipt as typeof receipt & { normalizedInputSha256: unknown };
|
||||
const legacy = { ...legacyFields, schema: "paperclip.semantic_tool_receipt.v1" };
|
||||
expect(parseSemanticToolReceipt(legacy)).toEqual(legacy);
|
||||
expect(parseSemanticToolReceipt({ ...legacy, normalizedInputSha256 })).toBeNull();
|
||||
expect(parseSemanticToolReceipt({ ...receipt, normalizedInputSha256: "null" })).toBeNull();
|
||||
expect(parseSemanticToolReceipt({ ...receipt, normalizedInputSha256: "a".repeat(65) })).toBeNull();
|
||||
expect(parseSemanticToolReceipt({ ...legacyFields, schema: "paperclip.semantic_tool_receipt.v2" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const SEMANTIC_RECEIPT_SCHEMA = "paperclip.semantic_tool_receipt.v1";
|
||||
export const SEMANTIC_RECEIPT_SCHEMA = "paperclip.semantic_tool_receipt.v2";
|
||||
const LEGACY_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;
|
||||
interface SemanticToolReceiptFields {
|
||||
operationId: string;
|
||||
callIdentitySha256: string;
|
||||
inputSha256: string;
|
||||
@@ -12,6 +12,10 @@ export interface SemanticToolReceipt {
|
||||
/** Transport outcome only: a returned value may explicitly reject a request. */
|
||||
outcome: "returned" | "error";
|
||||
}
|
||||
export type SemanticToolReceipt = SemanticToolReceiptFields & (
|
||||
| { schema: typeof LEGACY_SEMANTIC_RECEIPT_SCHEMA }
|
||||
| { schema: typeof SEMANTIC_RECEIPT_SCHEMA; normalizedInputSha256: string | null }
|
||||
);
|
||||
export interface SemanticToolResult {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
isError?: boolean;
|
||||
@@ -26,23 +30,31 @@ export function semanticCanonicalJson(value: unknown): string {
|
||||
}
|
||||
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
|
||||
if (!object(value)) return null;
|
||||
const legacy = value.schema === LEGACY_SEMANTIC_RECEIPT_SCHEMA;
|
||||
const keys = legacy ? "callIdentitySha256,inputSha256,operationId,outcome,resultSha256,schema"
|
||||
: "callIdentitySha256,inputSha256,normalizedInputSha256,operationId,outcome,resultSha256,schema";
|
||||
if (Object.keys(value).sort().join(",") !== keys
|
||||
|| (!legacy && 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))
|
||||
|| (!legacy && value.normalizedInputSha256 !== null && (typeof value.normalizedInputSha256 !== "string" || !/^[a-f0-9]{64}$/.test(value.normalizedInputSha256)))
|
||||
|| (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,
|
||||
const fields: SemanticToolReceiptFields = { operationId: value.operationId,
|
||||
callIdentitySha256: value.callIdentitySha256 as string, inputSha256: value.inputSha256 as string,
|
||||
resultSha256: value.resultSha256 as string, outcome: value.outcome };
|
||||
return legacy ? { schema: LEGACY_SEMANTIC_RECEIPT_SCHEMA, ...fields }
|
||||
: { schema: SEMANTIC_RECEIPT_SCHEMA, ...fields, normalizedInputSha256: value.normalizedInputSha256 as string | null };
|
||||
}
|
||||
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) {
|
||||
export function appendSemanticToolReceipt(call: { tool: string; callId: string; arguments: unknown; normalizedInputSha256?: string | null }, result: SemanticToolResult) {
|
||||
const receipt = parseSemanticToolReceipt({ schema: SEMANTIC_RECEIPT_SCHEMA, operationId: call.tool,
|
||||
callIdentitySha256: hash(call.callId), inputSha256: semanticInputSha256(call.arguments),
|
||||
normalizedInputSha256: call.normalizedInputSha256 ?? null,
|
||||
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) }] } };
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"commandDigest": "sha256:e3057abcc2e6eb0db3a5c683a15e32159ac3caa78ba3d4c2a5d9221929e867dd",
|
||||
"declaration": {
|
||||
"schema": "paperclip.acpx_profile_declaration.v1",
|
||||
"agent": "copilot",
|
||||
"agentProfileVersion": 11,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"protocolPolicyRevision": "copilot-agent-manual-v1",
|
||||
"acpxPatchSha256": "bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e",
|
||||
"policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3",
|
||||
"distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107",
|
||||
"agentFilesBinding": "registered-runtime-context-v1",
|
||||
"systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1",
|
||||
"sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1",
|
||||
"innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a",
|
||||
"upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0",
|
||||
"patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679",
|
||||
"messageIdentityContract": "copilot-native-message-id-v1",
|
||||
"ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1",
|
||||
"permissionContextContract": "copilot-edit-permission-context-v1",
|
||||
"permissionContextSourceSha256": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224",
|
||||
"permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce",
|
||||
"permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9",
|
||||
"permissionRedactionSourceSha256": "687270d7bbe3dccce6c2fed8664e76cda724b43e545cb153b9f54b018de52927",
|
||||
"permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4",
|
||||
"permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1",
|
||||
"semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2",
|
||||
"semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791",
|
||||
"semanticBridgeSourceSha256": "8a43ebafb7a85dd1714ed6945670472666dcb63e8af32b4a74644163fa0e370d",
|
||||
"toolEvidenceSourceSha256": "4b517eed447615b3cc820af348ccc360978175149caa0bbc838d1f78332abe75",
|
||||
"semanticNormalizedInputContract": "validated-forwarded-input-commit-v1",
|
||||
"semanticSidecarSourceSha256": "6c7dedca55f0f031570349b010ede1cd8015c25e9a52afac01ce9de8beb966c2",
|
||||
"semanticDirectDriverSourceSha256": "31e36917ee3592fa6d8f4b86632129fdffae20c45aed2e0e367ccf523e064f88",
|
||||
"semanticValidationSourceSha256": "039fca52266fe21ba74ca64660d21228314d8ed737b19ba67941bfb25086e4d5",
|
||||
"semanticNormalizationSourceSha256": "b1ce59251bf9c671f66e0e5173646190b9a2d1bfc5bdb7fb123824213986b92b",
|
||||
"semanticCompletionContractSourceSha256": "00a684f9aea87f68eabf0974681c52461bc308631a1252a89c4ed087d7158b28",
|
||||
"semanticValidatorsSourceSha256": "950b49b233d8dd62ff858398becfc55f0a181160cb8a434d9221b5c17816867c",
|
||||
"semanticSchemaBundleSourceSha256": "bb55e18c5563bf5ec226e301c3399229304f295e986865e96554f193535368f2"
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,112 @@ import {
|
||||
sanitizeRecord,
|
||||
} from "../redaction.js";
|
||||
|
||||
import { createCopilotToolEvidence } from "../../../packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.js";
|
||||
import { appendSemanticToolReceipt } from "../../../packages/paperclip-runner/src/drivers/semantic-tool-receipt.js";
|
||||
|
||||
function receiptNotice(version: 1 | 2 = 1): Record<string, any> {
|
||||
return {
|
||||
schema: "paperclip.provider.notice.v1", noticeId: `copilot-evidence-${"a".repeat(24)}-1`,
|
||||
severity: "info", category: `paperclip_semantic_tool_receipt_v${version}`, scope: "turn",
|
||||
recoverable: true, userActionable: false, summary: "Paperclip returned a semantic tool result.",
|
||||
provenance: { method: "paperclip/semantic_tool_result", eventType: "semantic_result", sessionId: "session", turnId: "turn" },
|
||||
details: Object.entries({ stage: "semantic_result", schema: `paperclip.semantic_tool_receipt.v${version}`,
|
||||
operationId: "finish_task", callIdentitySha256: "a".repeat(64), inputSha256: "b".repeat(64),
|
||||
resultSha256: "c".repeat(64), outcome: "returned", ...(version === 2 ? { normalizedInputSha256: "d".repeat(64) } : {}),
|
||||
}).map(([name, value]) => ({ name, value })),
|
||||
};
|
||||
}
|
||||
const receiptSchemaValue = (notice: Record<string, any>) => notice.details.find((detail: any) => detail.name === "schema").value;
|
||||
|
||||
describe("redaction", () => {
|
||||
it("preserves the actual Copilot receipt producer discriminator through nested durable redaction", () => {
|
||||
const events: Array<Record<string, any>> = [];
|
||||
const projector = createCopilotToolEvidence({ sessionId: "session", turnId: "turn", workingDirectory: "/workspace",
|
||||
active: () => true, emit: event => events.push(event) });
|
||||
const receipt = appendSemanticToolReceipt({ tool: "finish_task", callId: "call", arguments: {} },
|
||||
{ content: [{ type: "text", text: "accepted" }] }).receipt;
|
||||
expect(receipt.schema).toBe("paperclip.semantic_tool_receipt.v2");
|
||||
projector.captureSemanticReceipt()!(receipt);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.payload.category).toBe("paperclip_semantic_tool_receipt_v2");
|
||||
expect(events[0]!.payload.details).toHaveLength(8);
|
||||
const input = { prpEvent: { schema: "paperclip.prp.event.v1", schemaVersion: 1,
|
||||
eventType: "provider.notice.recorded", payload: events[0]!.payload } };
|
||||
expect(redactEventPayload(input)).toEqual(input);
|
||||
expect(redactEventPayload(redactEventPayload(input))).toEqual(input);
|
||||
});
|
||||
|
||||
it.each([1, 2] as const)("preserves only validated v%s receipt schema literals", version => {
|
||||
const notice = receiptNotice(version);
|
||||
expect(redactEventPayload(notice)).toEqual(notice);
|
||||
for (const outcome of ["returned", "error"]) {
|
||||
notice.details.find((d: any) => d.name === "outcome").value = outcome;
|
||||
if (version === 2) notice.details.find((d: any) => d.name === "normalizedInputSha256").value = "null";
|
||||
expect(redactEventPayload(notice)).toEqual(notice);
|
||||
}
|
||||
expect(redactEventPayload({ value: `paperclip.semantic_tool_receipt.v${version}` }))
|
||||
.toEqual({ value: REDACTED_EVENT_VALUE });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["wrong category", (n: Record<string, any>) => { n.category = "copilot_tool_evidence_v1"; }],
|
||||
["wrong schema", (n: Record<string, any>) => { n.schema = "paperclip.provider.native.v1"; }],
|
||||
["wrong scope", (n: Record<string, any>) => { n.scope = "session"; }],
|
||||
["wrong provenance", (n: Record<string, any>) => { n.provenance.method = "session/update"; }],
|
||||
["missing provenance", (n: Record<string, any>) => { delete n.provenance.turnId; }],
|
||||
["unknown provenance field", (n: Record<string, any>) => { n.provenance.extra = "safe"; }],
|
||||
["oversized identity", (n: Record<string, any>) => { n.provenance.sessionId = "x".repeat(241); }],
|
||||
["wrong stage", (n: Record<string, any>) => { n.details[0].value = "tool"; }],
|
||||
["unknown receipt schema", (n: Record<string, any>) => { n.details[1].value = "paperclip.semantic_tool_receipt.v99"; }],
|
||||
["version mismatch", (n: Record<string, any>) => { n.details[1].value = "paperclip.semantic_tool_receipt.v2"; }],
|
||||
["duplicate detail", (n: Record<string, any>) => { n.details[2] = { ...n.details[1] }; }],
|
||||
["unknown detail", (n: Record<string, any>) => { n.details[2].name = "unknown"; }],
|
||||
["extra detail", (n: Record<string, any>) => { n.details.push({ name: "extra", value: "safe" }); }],
|
||||
["extra detail property", (n: Record<string, any>) => { n.details[1].extra = "safe"; }],
|
||||
["nonstring hash", (n: Record<string, any>) => { n.details[3].value = 123; }],
|
||||
["malformed hash", (n: Record<string, any>) => { n.details[3].value = "bad"; }],
|
||||
["malformed operation", (n: Record<string, any>) => { n.details[2].value = "bad operation"; }],
|
||||
["unknown outcome", (n: Record<string, any>) => { n.details[6].value = "accepted"; }],
|
||||
] as const)("does not exempt receipt schema in %s context", (_label, mutate) => {
|
||||
const notice = receiptNotice(); mutate(notice);
|
||||
expect(receiptSchemaValue(redactEventPayload(notice)!)).toBe(REDACTED_EVENT_VALUE);
|
||||
});
|
||||
|
||||
it("does not restore JWTs or adjacent secrets through receipt-shaped data", () => {
|
||||
const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature12345678";
|
||||
const notice = receiptNotice();
|
||||
notice.details.find((d: any) => d.name === "operationId").value = jwt;
|
||||
notice.provenance.sessionId = jwt;
|
||||
const redacted = redactEventPayload(notice)! as Record<string, any>;
|
||||
expect(receiptSchemaValue(redacted)).toBe("paperclip.semantic_tool_receipt.v1");
|
||||
expect(redacted.details.find((d: any) => d.name === "operationId").value).toBe(REDACTED_EVENT_VALUE);
|
||||
expect(redacted.provenance.sessionId).toBe(REDACTED_EVENT_VALUE);
|
||||
expect(redactEventPayload({ notice, password: "canary", arbitrary: jwt }))
|
||||
.toMatchObject({ password: REDACTED_EVENT_VALUE, arbitrary: REDACTED_EVENT_VALUE });
|
||||
const hostile = receiptNotice(); hostile.details[1].value = jwt;
|
||||
expect(receiptSchemaValue(redactEventPayload(hostile)!)).toBe(REDACTED_EVENT_VALUE);
|
||||
const adjacent = receiptNotice(); adjacent.summary = "Authorization: Bearer canary-token";
|
||||
expect(JSON.stringify(redactEventPayload(adjacent))).not.toContain("canary-token");
|
||||
expect(receiptSchemaValue(redactEventPayload(adjacent)!)).toBe(REDACTED_EVENT_VALUE);
|
||||
});
|
||||
|
||||
it("bounds the notice ordinal to the actual producer's 2048-entry limit", () => {
|
||||
const notice = receiptNotice();
|
||||
notice.noticeId = `copilot-evidence-${"a".repeat(24)}-2048`;
|
||||
expect(redactEventPayload(notice)).toEqual(notice);
|
||||
for (const ordinal of ["0", "01", "2049", "9999"]) {
|
||||
notice.noticeId = `copilot-evidence-${"a".repeat(24)}-${ordinal}`;
|
||||
expect(receiptSchemaValue(redactEventPayload(notice)!)).toBe(REDACTED_EVENT_VALUE);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed v2 normalized hashes without changing historical v1", () => {
|
||||
const notice = receiptNotice(2);
|
||||
notice.details.find((d: any) => d.name === "normalizedInputSha256").value = "bad";
|
||||
expect(receiptSchemaValue(redactEventPayload(notice)!)).toBe(REDACTED_EVENT_VALUE);
|
||||
expect(redactEventPayload(receiptNotice(1))).toEqual(receiptNotice(1));
|
||||
});
|
||||
|
||||
it("keeps the discriminator allowlist in exact PRP v1 schema parity", () => {
|
||||
const schema = JSON.parse(
|
||||
readFileSync(
|
||||
|
||||
@@ -878,6 +878,45 @@ function isPaperclipSchemaDiscriminator(
|
||||
);
|
||||
}
|
||||
|
||||
/** Only restore the public literal in this closed receipt-notice context.
|
||||
* Details use {name,value}, so the ordinary schema-key exemption cannot apply.
|
||||
* Every other value still passes the generic secret/JWT redactor below. */
|
||||
function semanticReceiptSchemaDetail(record: Record<string, unknown>): number | null {
|
||||
if (Object.keys(record).sort().join(",") !== "category,details,noticeId,provenance,recoverable,schema,scope,severity,summary,userActionable"
|
||||
|| record.schema !== "paperclip.provider.notice.v1" || record.scope !== "turn"
|
||||
|| record.severity !== "info" || record.recoverable !== true || record.userActionable !== false
|
||||
|| record.summary !== "Paperclip returned a semantic tool result."
|
||||
|| typeof record.noticeId !== "string" || !/^copilot-evidence-[a-f0-9]{24}-[1-9][0-9]{0,3}$/.test(record.noticeId)
|
||||
|| Number(record.noticeId.slice(record.noticeId.lastIndexOf("-") + 1)) > 2048
|
||||
|| !isPlainObject(record.provenance) || !Array.isArray(record.details)) return null;
|
||||
const provenance = record.provenance;
|
||||
if (Object.keys(provenance).sort().join(",") !== "eventType,method,sessionId,turnId"
|
||||
|| provenance.method !== "paperclip/semantic_tool_result" || provenance.eventType !== "semantic_result"
|
||||
|| ![provenance.sessionId, provenance.turnId].every(value => typeof value === "string"
|
||||
&& value.length > 0 && value.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(value))) return null;
|
||||
const version = record.category === "paperclip_semantic_tool_receipt_v1" ? 1
|
||||
: record.category === "paperclip_semantic_tool_receipt_v2" ? 2 : null;
|
||||
if (version === null || record.details.length !== (version === 1 ? 7 : 8)) return null;
|
||||
const details = new Map<string, string>();
|
||||
let schemaIndex: number | null = null;
|
||||
for (const [index, detail] of record.details.entries()) {
|
||||
if (!isPlainObject(detail) || Object.keys(detail).sort().join(",") !== "name,value"
|
||||
|| typeof detail.name !== "string" || typeof detail.value !== "string" || details.has(detail.name)) return null;
|
||||
details.set(detail.name, detail.value);
|
||||
if (detail.name === "schema") schemaIndex = index;
|
||||
}
|
||||
const names = ["stage", "schema", "operationId", "callIdentitySha256", "inputSha256", "resultSha256", "outcome",
|
||||
...(version === 2 ? ["normalizedInputSha256"] : [])];
|
||||
const hex = (value: string | undefined) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
||||
if (!names.every(name => details.has(name)) || details.get("stage") !== "semantic_result"
|
||||
|| details.get("schema") !== `paperclip.semantic_tool_receipt.v${version}`
|
||||
|| !/^[A-Za-z0-9_.:-]{1,256}$/.test(details.get("operationId") ?? "")
|
||||
|| !["callIdentitySha256", "inputSha256", "resultSha256"].every(name => hex(details.get(name)))
|
||||
|| !["returned", "error"].includes(details.get("outcome") ?? "")
|
||||
|| (version === 2 && details.get("normalizedInputSha256") !== "null" && !hex(details.get("normalizedInputSha256")))) return null;
|
||||
return schemaIndex;
|
||||
}
|
||||
|
||||
export function sanitizeRecord(
|
||||
record: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
@@ -937,6 +976,12 @@ export function sanitizeRecord(
|
||||
}
|
||||
redacted[key] = sanitizeValue(value);
|
||||
}
|
||||
const schemaIndex = semanticReceiptSchemaDetail(record);
|
||||
if (schemaIndex !== null && Array.isArray(redacted.details)) {
|
||||
// Restore only the exact checked discriminator, never sibling data.
|
||||
(redacted.details[schemaIndex] as Record<string, unknown>).value =
|
||||
(record.details as Array<Record<string, unknown>>)[schemaIndex]!.value;
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user