fix(runner): bind normalized semantic receipt inputs

This commit is contained in:
Dotta committed 2026-09-30 23:23:28 -05:00
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"
}
}
+105
View File
@@ -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(
+45
View File
@@ -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;
}