fix(wasm): name the compare_wasm_interfaces side of rejected Evidence (#7302)

* fix(wasm): name the compare_wasm_interfaces side of rejected Evidence

compare_wasm_interfaces parsed both sides inside one try and reported any rejection, such as tampered content, a different operation or a rebound subject, as an input issue at path [], which names no argument. Each side is now parsed separately and a rejection is reported at [left] or [right], as the function, native and managed comparers already do.

* test(wasm): cover both comparison sides through the MCP boundary
This commit is contained in:
Adolanium authored and GitHub committed 2026-11-04 19:30:31 +08:00
1 parent d0d31fcf21
commit 1861059f8b
3 files changed
+96 -5

No files matched your search

@@ -168,6 +168,40 @@ it("requires the producer's file subject format even for authentic Evidence", as
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
});
it.each(["left", "right"] as const)(
"names the %s argument whose Evidence is rejected",
async (side) => {
const tampered = JSON.parse(JSON.stringify(evidence(facts("f"))));
tampered.normalized_result.interface.entries[0].name = "tampered";
const report = wasmArtifactFixture();
const otherSelection = createEvidence(
{
path: report.artifact.path,
sha256: report.artifact.sha256,
format: "file",
},
{ id: "fixture", name: "typed fixture", version: "1" },
{
operation: "inspect_wasm_artifact",
parameters: { path: "/artifacts/other.wasm" },
result: report,
confidence: "observed",
},
);
for (const rejected of [tampered, otherSelection])
expect(
await compareWasmInterfaces({
left: side === "left" ? rejected : evidence(facts("f")),
right: side === "right" ? rejected : evidence(facts("f")),
scope: "both",
}),
).toMatchObject({
ok: false,
error: { issues: [{ path: [side], reason: "invalid_value" }] },
});
},
);
it("resolves exact retained references and permits cancellation between comparison batches", async () => {
const left = evidence(facts("f"));
const right = evidence(facts("f", "i64"));
@@ -40,16 +40,19 @@ import {
const OPERATION = "compare_wasm_interfaces";
const parseInspection = (
raw: unknown,
side: "left" | "right",
): Result<{ evidence: Evidence; result: WasmArtifact }, AnalysisInputError> => {
const authenticated = safeParseEvidence(raw);
if (!authenticated.ok) return inspectionInputFailure(authenticated.error);
if (!authenticated.ok)
return inspectionInputFailure(authenticated.error, side);
const evidence = authenticated.value;
if (evidence.operation !== "inspect_wasm_artifact")
return inspectionInputFailure(
new TypeError("Expected complete inspect_wasm_artifact Evidence"),
side,
);
const parsed = wasmArtifactSchema.safeParse(evidence.normalized_result);
if (!parsed.success) return inspectionInputFailure(parsed.error);
if (!parsed.success) return inspectionInputFailure(parsed.error, side);
const result = parsed.data;
if (
!wasmArtifactMatchesSubject(
@@ -67,16 +70,19 @@ const parseInspection = (
new TypeError(
"WASM Evidence subject/selection does not match normalized artifact identity",
),
side,
);
return ok({ evidence, result });
};
/** Attribute a rejected side to the input argument that supplied it. */
const inspectionInputFailure = (
cause: Error,
side: "left" | "right",
): Result<never, AnalysisInputError> =>
err(
new AnalysisInputError(OPERATION, { cause }, [
{ path: [], reason: "invalid_value", message: cause.message },
{ path: [side], reason: "invalid_value", message: cause.message },
]),
);
@@ -107,9 +113,9 @@ export const compareWasmInterfaces = async (
try {
await setImmediate();
assertActive();
const parsedLeft = parseInspection(resolved.value.input.left);
const parsedLeft = parseInspection(resolved.value.input.left, "left");
if (!parsedLeft.ok) return parsedLeft;
const parsedRight = parseInspection(resolved.value.input.right);
const parsedRight = parseInspection(resolved.value.input.right, "right");
if (!parsedRight.ok) return parsedRight;
const left = parsedLeft.value;
const right = parsedRight.value;
@@ -33,6 +33,7 @@ import { OwnedFileHandle } from "../../src/filesystem/OwnedFileHandle.js";
import { primaryAnalysisError } from "../../src/domain/analysisErrorBase.js";
import { buildManagedPeFixture } from "../../src/dotnet/ManagedPe.fixture.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { wasmArtifactFixture } from "../fixtures/wasm/artifact.js";
import { ProcessCaptureError } from "../../src/process/capture/ProcessCaptureError.js";
import { reconstructionObligationLedgerSchema } from "../../src/domain/reconstructionObligationLedgerSchemas.js";
@@ -919,3 +920,53 @@ describe("native matching failure ownership through the public SDK", () => {
},
);
});
describe("compare_wasm_interfaces Evidence attribution", () => {
const inspection = (selectedPath?: string) => {
const report = wasmArtifactFixture();
return createEvidence(
{
path: report.artifact.path,
sha256: report.artifact.sha256,
format: "file",
},
{ id: "typed-fixture", name: "Typed fixture", version: "1" },
{
operation: "inspect_wasm_artifact",
parameters: { path: selectedPath ?? report.artifact.path },
result: report,
confidence: "observed",
},
);
};
it.each(["left", "right"] as const)(
"names the %s argument whose authentic Evidence selects another artifact",
async (side) => {
await withSdk(async ({ client, session }) => {
const rejected = inspection("/artifacts/other.wasm");
const response = await client.callTool({
name: "compare_wasm_interfaces",
arguments: {
left: side === "left" ? rejected : inspection(),
right: side === "right" ? rejected : inspection(),
scope: "both",
},
});
expect(parseMcpToolError(response).error).toMatchObject({
code: "invalid_request",
details: {
issues: [
expect.objectContaining({
path: [side],
reason: "invalid_value",
}),
],
},
});
expect(session.exportEvidenceBundle().records).toEqual([]);
});
},
);
});