From 911894d75822116141897a582c1e26e1d0273dba Mon Sep 17 00:00:00 2001 From: Thang Luu Date: Wed, 14 Oct 2026 00:49:04 +0900 Subject: [PATCH] feat(wasm): compare retained core-module interfaces (#2464) Squash merge of https://github.com/morluto/rea/pull/2464. Co-authored-by: Thang Luu <76550342+thangldw@users.noreply.github.com> --- .../references/native-and-artifacts.md | 6 + .../2356-wasm-interface-comparison/plan.md | 10 + .../2356-wasm-interface-comparison/spec.md | 61 ++++ .../2356-wasm-interface-comparison/tasks.md | 8 + .../verification.md | 47 +++ docs/wasm-artifacts.md | 43 +++ package.json | 1 + scripts/lib/catalog-builders.mjs | 4 +- scripts/verify-wasm-artifact.mjs | 18 +- scripts/verify-wasm-interface-comparison.mjs | 208 +++++++++++ .../WasmInterfaceComparisonService.test.ts | 198 +++++++++++ .../wasm/WasmInterfaceComparisonService.ts | 250 ++++++++++++++ src/cli/wasmCommands.ts | 26 ++ src/cliCommandNames.ts | 1 + src/contracts/toolEffects.test.ts | 2 + src/contracts/toolEffects.ts | 1 + src/contracts/wasm/wasmComparisonContracts.ts | 63 ++++ src/contracts/wasm/wasmToolContracts.ts | 6 +- src/domain/wasm/wasmArtifact.ts | 2 + src/domain/wasm/wasmInterface.ts | 109 ++++++ src/server/registerWasmTools.ts | 36 ++ src/wasm/WabtArtifactProvider.ts | 10 +- src/wasm/WabtInterface.test.ts | 116 +++++++ src/wasm/WabtInterface.ts | 325 ++++++++++++++++++ src/wasm/WabtOutput.test.ts | 4 +- src/wasm/WabtString.ts | 46 +++ src/wasm/WabtWat.ts | 63 +--- src/wasm/WasmInterfaceBudget.ts | 17 + .../wasmInterfaceComparison.test.ts | 130 +++++++ 29 files changed, 1757 insertions(+), 54 deletions(-) create mode 100644 docs/specs/2356-wasm-interface-comparison/plan.md create mode 100644 docs/specs/2356-wasm-interface-comparison/spec.md create mode 100644 docs/specs/2356-wasm-interface-comparison/tasks.md create mode 100644 docs/specs/2356-wasm-interface-comparison/verification.md create mode 100644 scripts/verify-wasm-interface-comparison.mjs create mode 100644 src/application/wasm/WasmInterfaceComparisonService.test.ts create mode 100644 src/application/wasm/WasmInterfaceComparisonService.ts create mode 100644 src/contracts/wasm/wasmComparisonContracts.ts create mode 100644 src/domain/wasm/wasmInterface.ts create mode 100644 src/wasm/WabtInterface.test.ts create mode 100644 src/wasm/WabtInterface.ts create mode 100644 src/wasm/WabtString.ts create mode 100644 src/wasm/WasmInterfaceBudget.ts create mode 100644 tests/acceptance/wasmInterfaceComparison.test.ts diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index 33b9a9e79..3e185a8a2 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -249,3 +249,9 @@ resolution is explicit; missing references and size mismatch remain visible. Other payloads are opaque, and this operation does not decode images or simulate Windows resource loading. Consult the budgets and supported tree profile in [offline binary diagnostics](https://github.com/morluto/rea/blob/main/docs/binary-diagnostics.md). + +For exact core-module import/export differences, use `compare_wasm_interfaces` +with two complete inspection Evidence records (or current-session retained +references) and explicit scope. Preserve unknown types/duplicate names; static +interface equality is not runtime compatibility. CLI accepts complete inline +Evidence in `rea compare-wasm-interfaces input.json --json`. diff --git a/docs/specs/2356-wasm-interface-comparison/plan.md b/docs/specs/2356-wasm-interface-comparison/plan.md new file mode 100644 index 000000000..a5b9e8ac3 --- /dev/null +++ b/docs/specs/2356-wasm-interface-comparison/plan.md @@ -0,0 +1,10 @@ +# Plan + +1. Check issue/timeline and complete open/closed PR searches; review #2406. +2. Extend the existing core-module WABT inspection in an isolated worktree. +3. Extend producer-owned inspection with optional typed interface facts; + preserve unknown unsupported signatures and exact WAT forms. +4. Add shared Evidence-only comparator and strict input/output contracts. +5. Register additive CLI/MCP workflow and documentation. +6. Validate original-module workflows, malformed/unknown/cancellation cases; + run checks, independent review, repair findings and rerun affected checks. diff --git a/docs/specs/2356-wasm-interface-comparison/spec.md b/docs/specs/2356-wasm-interface-comparison/spec.md new file mode 100644 index 000000000..b4dfb7778 --- /dev/null +++ b/docs/specs/2356-wasm-interface-comparison/spec.md @@ -0,0 +1,61 @@ +# Core WebAssembly interface comparison (#2356) + +## Outcome and scope + +Compare two exact authenticated `inspect_wasm_artifact` Evidence inputs, inline +or retained session references, under required `scope: imports | exports | both`. +No module acquisition or execution occurs during comparison. This is a derived +workflow within existing WABT inspection authority, not a new provider. + +Producer-owned typed `interface` facts are additive and optional in historical +inspection Evidence. WABT's validated canonical WAT owns decoding; exact original +forms and names remain available. Comparison never reparses retained display text. +Function/tag signatures resolve type uses; memory page size defaults to 65536 bytes and explicit custom page sizes are retained; table/memory limits and global types +are compared exactly. Unsupported type/proposal forms remain unknown rather than +being compared by raw text or inferred compatible. Names are exact decoded UTF-8, +without normalization, including empty names, escapes, punctuation and BOM. + +## Comparison contract + +Return both Evidence IDs and original artifact path/digest/length. Group imports +by exact module/name and exports by exact name, independently of kind, so a kind +change is reported as changed. Unique admitted signatures yield added, removed, +changed or unchanged. Duplicate keys retain every original occurrence and yield +unknown; no Cartesian pairing or deduplication. Missing historical facets and +incomplete inventories prevent absence claims. Unknown signatures do not erase +independently observed entries. Ordering is deterministic by direction and names. + +Both subject digest/path and inspection parameters must match normalized artifact +identity. Authenticate Evidence before projection; summary Evidence must be +resolved to its retained complete parent by the caller. Report unknown coverage +and explicit limitations. Exact static equality is not runtime compatibility, +ABI compatibility, linking success or source identity. + +## Authority and overlap + +Reads supplied Evidence only. MCP records derived Evidence through the existing +handler/ledger; CLI accepts portable inline Evidence from an explicit JSON input. +No new provider, implicit fetch, target mutation or foreign-code execution. +PR #2406 (head 86865035) adds component inspection, preamble routing and embedded +core-module inspection. Its component parser is not required or duplicated here; +shared contract/CLI registration changes remain additive. Core GC/recursive types +are outside admitted signature semantics and keep unknown mappings. + +## Resource admission + +Typed producer rows, names and declaration tokenization are admitted before +allocation against the existing 32 MiB WABT output representation capacity. +Comparison uses linear grouping and cooperative transport yields; it never +builds Cartesian duplicate pairings. CLI JSON input retains the shared file +admission gate. Token usage and peak process-family memory remain unmeasured. + +## Verification + +Original owned modules assembled using WABT 1.0.42 cover semantically equivalent +encodings, changes of kind/signature, names/duplicates, imported index spaces, +tables/memory/globals/tags, unknown types and historical incomplete Evidence. +Exercise authenticated identity, cancellation, retained lookup, CLI/MCP parity +and advertised schemas. Run focused existing WASM/Evidence tests, repository +checks and docs validation; independent subagent review before handoff. + +Format reference: https://webassembly.github.io/spec/core/binary/modules.html diff --git a/docs/specs/2356-wasm-interface-comparison/tasks.md b/docs/specs/2356-wasm-interface-comparison/tasks.md new file mode 100644 index 000000000..722ace2c9 --- /dev/null +++ b/docs/specs/2356-wasm-interface-comparison/tasks.md @@ -0,0 +1,8 @@ +# Tasks + +- [x] Confirm scope and overlap boundaries against #2406. +- [x] Record spec and implementation plan. +- [x] Implement typed producer interface and shared comparison. +- [x] Add CLI/MCP contracts and public workflow tests. +- [x] Run focused, static and documentation checks. +- [x] Resolve independent review findings and rerun relevant checks. diff --git a/docs/specs/2356-wasm-interface-comparison/verification.md b/docs/specs/2356-wasm-interface-comparison/verification.md new file mode 100644 index 000000000..ca531e8e4 --- /dev/null +++ b/docs/specs/2356-wasm-interface-comparison/verification.md @@ -0,0 +1,47 @@ +# Verification + +Verified base: a2d8b94dd50dab8d966239dd63ab84718aa8b4b4 (fetched main). +The change was reapplied without conflicts and relevant checks rerun. + +- Focused existing/new parser, provider, service, contract and CLI/MCP tests: + 8 files / 56 tests passed. +- After the final partial-uniqueness correction, reran service and compiled + CLI/MCP boundary tests: 2 files / 5 tests passed. +- `npm run check`: typecheck, lint, formatting, knip, module boundaries and + architecture guards passed. Lint reports warnings; no errors. +- `npm run docs:generate`, `npm run docs:check` and + `npm run verify:test-discovery` passed; every test file was discovered exactly once. +- Real WABT 1.0.42 comparison through compiled CLI and stdio MCP passed: + equivalent padded LEB128 original encodings, function signatures, all admitted + kinds, custom memory page size, escaped/BOM names, empty interfaces, explicit + scope, duplicate imports and unadmitted exception reference signatures. +- Existing real WABT artifact verifier passed after correcting its stale inline + partial-observation assumption to resolve the MCP retained reference from the + Evidence bundle and authenticate the original record. + +## Independent review + +Three findings repaired at their owning boundary: + +1. Memory page size omitted: now preserved, default 65536 canonicalized. +2. Shared signature expansion undercounted: charge every retained occurrence. + Reviewer independently measured the 1000-params/10000-exports case: original + 262956-byte WAT produced 61824014-byte JSON before correction. It now retains + 1949 observations in a partial facet, with 12046676-byte JSON and an explicit + capacity issue. Original WAT stays available; incomplete coverage prevents + absence and uniqueness claims. +3. Partial singleton uniqueness: comparison now returns unknown whenever either + selected inventory is incomplete, including matched names. + +Final independent source review reported no actionable findings. Review does not +replace the executed tests or real-tool evidence. + +## Scope and measurement limits + +On the original implementation base f254f5a, the complete comparison verifier reported 7.98 seconds elapsed, 6.56 user and +3.13 system seconds, and 429441024 bytes maximum RSS via macOS `/usr/bin/time -l`. +These are command-accounting measurements, not aggregate concurrent process-family +memory or model tokens. Token usage and full process-family peak are unmeasured. +Real-tool verification covers macOS arm64 only; Linux/Windows remain unverified. +No module was instantiated/executed and no source was fetched. Static equality +is not runtime compatibility. Full CI is separate from this local verification record. diff --git a/docs/wasm-artifacts.md b/docs/wasm-artifacts.md index 6bc7c23ce..364096251 100644 --- a/docs/wasm-artifacts.md +++ b/docs/wasm-artifacts.md @@ -79,3 +79,46 @@ Both CLI and MCP use the same typed port and application service. MCP publishes SDK-compatible schemas and records Evidence for `get_evidence_bundle` readback. See [WABT provenance](https://github.com/morluto/rea/blob/main/third_party/wabt/README.md) and [testing](testing.md#offline-wasm-artifacts) for real-tool verification. + +## Compare core-module interfaces + +Inspection adds producer-owned typed `interface` facts while keeping exact WAT +import/export forms. Function/tag signatures resolve type indices; table/memory +limits, memory page size and global value type/mutability remain explicit. +Unadmitted types (including GC reference signatures) retain null signatures and +issues. Exact decoded names retain escapes, empty names, BOM and punctuation. +This facet is absent in historical Evidence that did not examine typed interfaces. + +`compare_wasm_interfaces` accepts two complete `inspect_wasm_artifact` Evidence +records, or exact retained references in the current MCP session, and required +`scope: "imports" | "exports" | "both"`. It reads no files and needs no active +WABT toolchain. CLI uses portable inline Evidence in one JSON document: + +```bash +rea compare-wasm-interfaces comparison-input.json --json +``` + +```json +{ + "left": { "kind": "retained-evidence", "evidence_id": "ev_" }, + "right": { "kind": "retained-evidence", "evidence_id": "ev_" }, + "scope": "both" +} +``` + +The references above are MCP-only; put complete Evidence objects in the CLI +input. Resolve summary delivery to its retained complete parent before comparing. +Results retain both input Evidence IDs, artifact paths/digests/lengths and every +selected occurrence. Unique names are added, removed, changed or unchanged; +duplicate names and unsupported signatures are unknown. Incomplete or historical +inventories never establish absence. Imports group by module/name, exports by +name, so a change of kind is visible. Output ordering is deterministic. + +Static type equality does not establish runtime compatibility, ABI compatibility +or linking success. Comparison covers core modules; WebAssembly components and +canonical ABI relationships belong to their separate inspection workflow. + +After building, run `REA_WABT_BIN_DIRECTORY=/absolute/wabt/bin node +scripts/verify-wasm-interface-comparison.mjs` for original-module comparison +through the compiled CLI and stdio MCP. It uses owned fixtures and WABT 1.0.42; +no WASM module is instantiated or executed. diff --git a/package.json b/package.json index 70ab60d1e..b042eb106 100644 --- a/package.json +++ b/package.json @@ -269,6 +269,7 @@ "verify:binary:layout": "npm run build:cached && node scripts/verify-binary-layout.mjs", "verify:go:binary": "npm run build:cached && node scripts/verify-go-binary.mjs", "verify:wasm:package": "npm run build:cached && node scripts/verify-packaged-wasm-artifact.mjs", + "verify:wasm-interface-comparison": "npm run build:cached && node scripts/verify-wasm-interface-comparison.mjs", "verify:wasm:artifact": "npm run build:cached && node scripts/verify-wasm-artifact.mjs", "verify:evm:interface": "npm run build:cached && node scripts/verify-evm-interface.mjs", "verify:solidity:metadata": "npm run build:cached && node scripts/verify-solidity-metadata.mjs", diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index 32ec51ee3..d924ef49c 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -213,7 +213,9 @@ export const providerCatalog = (sources) => { }, { identity: sources.wasmProvider.WABT_PROVIDER_IDENTITY, - contracts: sources.wasmContracts.WASM_TOOL_CONTRACTS, + contracts: sources.wasmContracts.WASM_TOOL_CONTRACTS.filter( + ({ kind }) => kind === "artifact-provider", + ), }, { identity: sources.evmProvider.EVMOLE_PROVIDER_IDENTITY, diff --git a/scripts/verify-wasm-artifact.mjs b/scripts/verify-wasm-artifact.mjs index 1f3bde2ca..305063ccc 100644 --- a/scripts/verify-wasm-artifact.mjs +++ b/scripts/verify-wasm-artifact.mjs @@ -310,7 +310,23 @@ try { }), ); assert.equal(error.category, "invalid_input"); - const failed = parseEvidence(error.details.partial_observation); + const partial = error.details.partial_observation; + assert.equal(partial.kind, "retained-evidence"); + const failedBundleReply = await client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }); + assert.notEqual( + failedBundleReply.isError, + true, + mcpTextValue(failedBundleReply), + ); + const failedBundle = JSON.parse(mcpTextValue(failedBundleReply)).result; + const failed = parseEvidence( + failedBundle.records.find( + ({ evidence_id }) => evidence_id === partial.evidence_id, + ), + ); assert.equal(failed.subject.digest.sha256, sha256(invalidBytes)); assert.equal( failed.normalized_result.validation, diff --git a/scripts/verify-wasm-interface-comparison.mjs b/scripts/verify-wasm-interface-comparison.mjs new file mode 100644 index 000000000..6b215eef3 --- /dev/null +++ b/scripts/verify-wasm-interface-comparison.mjs @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises"; +import { join, resolve, isAbsolute } from "node:path"; +import { tmpdir } from "node:os"; +import { promisify } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { parseEvidence } from "../dist/domain/evidence.js"; + +const execute = promisify(execFile); +const bin = process.env.REA_WABT_BIN_DIRECTORY; +if (!isAbsolute(bin ?? "")) + throw new Error("Supply absolute REA_WABT_BIN_DIRECTORY with WABT 1.0.42"); +const root = await mkdtemp(join(tmpdir(), "rea-wasm-interface-real-")); +const cli = resolve("scripts/rea.mjs"); +const environment = { ...process.env }; +const command = async (file, args) => + ( + await execute(file, args, { + env: environment, + cwd: root, + timeout: 120000, + maxBuffer: 64 * 1024 * 1024, + }) + ).stdout; +const client = new Client({ + name: "wasm-interface-real-verifier", + version: "1", +}); +const transport = new StdioClientTransport({ + command: process.execPath, + args: [cli, "mcp"], + env: environment, + stderr: "pipe", +}); +const source = (parameter) => + `(module (type (func (param ${parameter}) (result i64))) (import "env" "f" (func (type 0))) (table 1 3 funcref) (memory 1 2) (global (mut i64) (i64.const 0)) (tag (param i32)) (export "run\\22\\ef\\bb\\bf" (func 0)) (export "table" (table 0)) (export "memory" (memory 0)) (export "global" (global 0)) (export "tag" (tag 0)))`; +const assemble = async (name, wat) => { + await writeFile(join(root, `${name}.wat`), wat); + const path = join(root, `${name}.wasm`); + await command(join(bin, "wat2wasm"), [ + "--enable-all", + `${name}.wat`, + "-o", + path, + ]); + return path; +}; +const inspect = async (path) => { + const result = await client.callTool({ + name: "inspect_wasm_artifact", + arguments: { path }, + }); + assert.notEqual(result.isError, true, JSON.stringify(result)); + return parseEvidence(result.structuredContent); +}; +const compare = async (left, right, scope = "both") => { + const input = { + left: { kind: "retained-evidence", evidence_id: left.evidence_id }, + right: { kind: "retained-evidence", evidence_id: right.evidence_id }, + scope, + }; + const reply = await client.callTool({ + name: "compare_wasm_interfaces", + arguments: input, + }); + assert.notEqual(reply.isError, true, JSON.stringify(reply)); + const mcp = parseEvidence(reply.structuredContent); + const file = join(root, "comparison.json"); + await writeFile(file, JSON.stringify({ left, right, scope })); + const local = parseEvidence( + JSON.parse( + await command(process.execPath, [ + cli, + "compare-wasm-interfaces", + file, + "--json", + ]), + ), + ); + assert.deepEqual(local, mcp); + return mcp.normalized_result; +}; +try { + assert.equal( + (await command(join(bin, "wat2wasm"), ["--version"])).trim(), + "1.0.42", + ); + await client.connect(transport); + const path = await assemble("baseline", source("i32")); + const original = await readFile(path); + assert.equal(original[8], 1); + assert.ok(original[9] < 128); + // Section lengths allow padded u32 LEB128 encodings. Different original + // byte identities must compare equal after validated type resolution. + const equivalentPath = join(root, "equivalent.wasm"); + await writeFile( + equivalentPath, + Buffer.concat([ + original.subarray(0, 9), + Buffer.from([original[9] | 128, 0]), + original.subarray(10), + ]), + ); + const left = await inspect(path); + const equivalent = await inspect(equivalentPath); + assert.notEqual(left.subject.digest.sha256, equivalent.subject.digest.sha256); + const same = await compare(left, equivalent); + assert.deepEqual(same.summary, { + added: 0, + removed: 0, + changed: 0, + unchanged: 6, + unknown: 0, + }); + const facts = left.normalized_result.interface.entries; + assert.deepEqual(facts.find((e) => e.name === "memory").signature, { + kind: "memory", + page_size: "65536", + address_type: "i32", + minimum: "1", + maximum: "2", + shared: false, + }); + assert.deepEqual(facts.find((e) => e.name === "tag").signature, { + kind: "tag", + params: ["i32"], + results: [], + }); + assert.deepEqual(facts.find((e) => e.name === 'run"\ufeff').signature, { + kind: "func", + params: ["i32"], + results: ["i64"], + }); + const changed = await inspect(await assemble("changed", source("i64"))); + assert.deepEqual((await compare(left, changed)).summary, { + added: 0, + removed: 0, + changed: 2, + unchanged: 4, + unknown: 0, + }); + const smallPage = await inspect( + await assemble( + "small-page", + '(module (memory 1 2 (pagesize 1)) (export "memory" (memory 0)))', + ), + ); + const defaultPage = await inspect( + await assemble( + "default-page", + '(module (memory 1 2) (export "memory" (memory 0)))', + ), + ); + assert.equal((await compare(defaultPage, smallPage)).summary.changed, 1); + const empty = await inspect(await assemble("empty", "(module)")); + assert.equal((await compare(left, empty)).summary.removed, 6); + assert.equal((await compare(empty, left, "exports")).summary.added, 5); + const duplicate = await inspect( + await assemble( + "duplicate", + '(module (import "e" "same" (func (param i32))) (import "e" "same" (func (param i64))))', + ), + ); + const duplicates = await compare(duplicate, duplicate, "imports"); + assert.equal(duplicates.summary.unknown, 1); + assert.equal(duplicates.changes[0].left.length, 2); + const unsupported = await inspect( + await assemble( + "exnref", + '(module (import "e" "abstract-ref" (global exnref)))', + ), + ); + assert.equal((await compare(unsupported, unsupported)).summary.unknown, 1); + console.log( + JSON.stringify( + { + verified: true, + toolchain: "WABT 1.0.42", + transport: "compiled CLI + stdio MCP", + interface_fact_bytes: Buffer.byteLength( + JSON.stringify(left.normalized_result.interface), + ), + comparison_result_bytes: Buffer.byteLength(JSON.stringify(same)), + original_sha256: left.subject.digest.sha256, + equivalent_sha256: equivalent.subject.digest.sha256, + cases: [ + "equivalent padded LEB128", + "function signature change", + "all admitted kinds", + "custom memory page-size change", + "escaped/BOM name", + "empty interface additions/removals", + "scope", + "duplicate imports", + "unsupported exception reference", + ], + runtime_execution: "not-performed", + }, + null, + 2, + ), + ); +} finally { + await client.close(); + await rm(root, { recursive: true, force: true }); +} diff --git a/src/application/wasm/WasmInterfaceComparisonService.test.ts b/src/application/wasm/WasmInterfaceComparisonService.test.ts new file mode 100644 index 000000000..9936c7530 --- /dev/null +++ b/src/application/wasm/WasmInterfaceComparisonService.test.ts @@ -0,0 +1,198 @@ +import { expect, it } from "vitest"; +import { createEvidence } from "../../domain/evidence.js"; +import { wasmInterfaceComparisonSchema } from "../../contracts/wasm/wasmComparisonContracts.js"; +import { compareWasmInterfaces } from "./WasmInterfaceComparisonService.js"; +import type { WasmInterface } from "../../domain/wasm/wasmInterface.js"; +import { wasmArtifactFixture } from "../../../tests/fixtures/wasm/artifact.js"; + +const facts = ( + name: string, + parameter: "i32" | "i64" = "i32", +): WasmInterface => ({ + state: "complete", + issues: [], + entries: [ + { + direction: "export", + module: null, + name, + kind: "func", + index: 0, + signature: { kind: "func", params: [parameter], results: [] }, + wat_form: `(export "${name}" (func 0))`, + issue: null, + }, + ], +}); +const evidence = (interfaceFacts?: WasmInterface) => { + const report = wasmArtifactFixture(); + if (interfaceFacts !== undefined) report.interface = interfaceFacts; + return createEvidence( + { + path: report.artifact.path, + sha256: report.artifact.sha256, + format: "file", + }, + { id: "fixture", name: "typed fixture", version: "1" }, + { + operation: "inspect_wasm_artifact", + parameters: { path: report.artifact.path }, + result: report, + confidence: "observed", + }, + ); +}; +const compare = async ( + left: ReturnType, + right: ReturnType, +) => { + const result = await compareWasmInterfaces({ left, right, scope: "both" }); + if (!result.ok) throw result.error; + return wasmInterfaceComparisonSchema.parse(result.value.normalized_result); +}; + +it("reports all five comparison states, including duplicate ambiguity and explicit selected scope", async () => { + const left = facts("changed"); + const right = facts("changed", "i64"); + left.entries.push( + ...facts("unchanged").entries, + ...facts("removed").entries, + ...facts("dup").entries, + ...facts("dup").entries, + ); + right.entries.push( + ...facts("unchanged").entries, + ...facts("added").entries, + ...facts("dup").entries, + ); + const report = await compare(evidence(left), evidence(right)); + expect(report.summary).toEqual({ + changed: 1, + unchanged: 1, + removed: 1, + added: 1, + unknown: 1, + }); + expect(report.changes.find((c) => c.name === "dup")).toMatchObject({ + state: "unknown", + left: [expect.anything(), expect.anything()], + right: [expect.anything()], + }); + const selected = await compareWasmInterfaces({ + left: evidence(left), + right: evidence(right), + scope: "imports", + }); + if (!selected.ok) throw selected.error; + expect(selected.value.normalized_result).toMatchObject({ + changes: [], + coverage: "complete-within-inputs", + }); +}); + +it("withholds absence claims for historical or partial inventories and unsupported signatures", async () => { + const known = facts("f"); + const absent = await compare(evidence(), evidence(known)); + expect(absent).toMatchObject({ + coverage: "partial", + changes: [{ state: "unknown" }], + }); + const partial: WasmInterface = { + state: "partial", + issues: ["unread import table"], + entries: [], + }; + expect(await compare(evidence(partial), evidence(known))).toMatchObject({ + changes: [{ state: "unknown" }], + }); + const unsupported = facts("f"); + unsupported.entries[0]!.signature = null; + expect(await compare(evidence(known), evidence(unsupported))).toMatchObject({ + changes: [{ state: "unknown" }], + }); + const partialMatch = facts("f"); + partialMatch.state = "partial"; + partialMatch.issues = ["Later imports were not examined"]; + expect(await compare(evidence(partialMatch), evidence(known))).toMatchObject({ + changes: [{ state: "unknown" }], + }); + expect(await compare(evidence(), evidence())).toMatchObject({ + coverage: "unknown", + changes: [], + }); +}); + +it("authenticates semantic Evidence and binds display subject and selection identity", async () => { + const left = JSON.parse(JSON.stringify(evidence(facts("f")))); + left.normalized_result.interface.entries[0].name = "tampered"; + expect( + await compareWasmInterfaces({ + left, + right: evidence(facts("f")), + scope: "both", + }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); + const rebound = JSON.parse(JSON.stringify(evidence(facts("f")))); + rebound.subject.local_path = "/other.wasm"; + expect( + await compareWasmInterfaces({ + left: rebound, + right: evidence(facts("f")), + scope: "both", + }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); +}); + +it("resolves exact retained references and permits cancellation between comparison batches", async () => { + const left = evidence(facts("f")); + const right = evidence(facts("f", "i64")); + const references = { + left: { kind: "retained-evidence", evidence_id: left.evidence_id }, + right: { kind: "retained-evidence", evidence_id: right.evidence_id }, + scope: "both", + }; + expect(await compareWasmInterfaces(references)).toMatchObject({ + ok: false, + error: { _tag: "EvidenceIntegrityError" }, + }); + const result = await compareWasmInterfaces(references, {}, (id) => + id === left.evidence_id ? left : right, + ); + if (!result.ok) throw result.error; + expect(result.value.evidence_links).toEqual([ + left.evidence_id, + right.evidence_id, + ]); + const large = facts("f"); + large.entries.push( + ...Array.from({ length: 2000 }, (_, i) => facts(`f${i}`).entries[0]!), + ); + const controller = new AbortController(); + const pending = compareWasmInterfaces( + { left: evidence(large), right: evidence(large), scope: "both" }, + { signal: controller.signal }, + ); + setImmediate(() => controller.abort()); + expect(await pending).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); +}); + +it.each([false, true])( + "keeps a one-sided unsupported interface unknown with reverse=%s", + async (reverse) => { + const empty: WasmInterface = { state: "complete", issues: [], entries: [] }; + const unsupported = facts("exception"); + unsupported.entries[0]!.signature = null; + unsupported.entries[0]!.issue = "Unsupported exnref global type"; + const sides = reverse ? [unsupported, empty] : [empty, unsupported]; + expect(await compare(evidence(sides[0]), evidence(sides[1]))).toMatchObject( + { + coverage: "partial", + summary: { added: 0, removed: 0, unknown: 1 }, + changes: [{ state: "unknown" }], + }, + ); + }, +); diff --git a/src/application/wasm/WasmInterfaceComparisonService.ts b/src/application/wasm/WasmInterfaceComparisonService.ts new file mode 100644 index 000000000..f920a5dea --- /dev/null +++ b/src/application/wasm/WasmInterfaceComparisonService.ts @@ -0,0 +1,250 @@ +import { + WASM_INTERFACE_REPRESENTATION_BYTES, + wasmInterfaceEntryRepresentationBytes, +} from "../../domain/wasm/wasmInterface.js"; +import { setImmediate } from "node:timers/promises"; +import { + compareWasmInterfacesInputSchema, + wasmInterfaceComparisonSchema, +} from "../../contracts/wasm/wasmComparisonContracts.js"; +import { + createEvidence, + parseEvidence, + type Evidence, +} from "../../domain/evidence.js"; +import { + wasmArtifactSchema, + type WasmArtifact, +} from "../../domain/wasm/wasmArtifact.js"; +import type { WasmInterfaceEntry } from "../../domain/wasm/wasmInterface.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../../domain/analysisErrorCore.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { ARTIFACT_COMPARISON_PROVIDER } from "../InvestigationProviders.js"; +import { + resolvePairedEvidenceRequest, + type EvidenceLookup, +} from "../EvidenceInputResolver.js"; +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { z } from "zod"; + +const OPERATION = "compare_wasm_interfaces"; +const parseInspection = ( + raw: unknown, +): { evidence: Evidence; result: WasmArtifact } => { + const evidence = parseEvidence(raw); + if (evidence.operation !== "inspect_wasm_artifact") + throw new TypeError("Expected complete inspect_wasm_artifact Evidence"); + const result = wasmArtifactSchema.parse(evidence.normalized_result); + if ( + evidence.subject?.digest.sha256 !== result.artifact.sha256 || + evidence.subject.local_path !== result.artifact.path || + evidence.parameters.path !== result.artifact.path + ) + throw new TypeError( + "WASM Evidence subject/selection does not match normalized artifact identity", + ); + return { evidence, result }; +}; + +/** Compare only producer-owned typed facts; no raw WAT or original file is reread. */ +export const compareWasmInterfaces = async ( + rawInput: unknown, + options?: ExecutionOptions, + lookup?: EvidenceLookup, +): Promise> => { + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); + const parsed = compareWasmInterfacesInputSchema.safeParse(rawInput); + if (!parsed.success) + return err( + analysisInputErrorFromIssues(OPERATION, parsed.error.issues, rawInput), + ); + const resolved = resolvePairedEvidenceRequest(parsed.data, lookup); + if (!resolved.ok) return resolved; + try { + await setImmediate(); + options?.signal?.throwIfAborted(); + const left = parseInspection(resolved.value.left); + const right = parseInspection(resolved.value.right); + const scope = parsed.data.scope; + const selected = (entry: WasmInterfaceEntry): boolean => + scope === "both" || + entry.direction === (scope === "imports" ? "import" : "export"); + const groups = new Map< + string, + { + left: WasmInterfaceEntry[]; + right: WasmInterfaceEntry[]; + entry: WasmInterfaceEntry; + } + >(); + let work = 0; + for (const [side, inspection] of [ + ["left", left], + ["right", right], + ] as const) { + for (const entry of inspection.result.interface?.entries ?? []) { + if (++work % 256 === 0) { + await setImmediate(); + options?.signal?.throwIfAborted(); + } + if (!selected(entry)) continue; + const key = JSON.stringify([entry.direction, entry.module, entry.name]); + let group = groups.get(key); + if (group === undefined) { + group = { left: [], right: [], entry }; + groups.set(key, group); + } + group[side].push(entry); + } + } + const knownInventory = (inspection: typeof left): boolean => + inspection.result.interface?.state === "complete"; + const changes: z.infer["changes"] = + []; + const summary = { + added: 0, + removed: 0, + changed: 0, + unchanged: 0, + unknown: 0, + }; + let remaining = WASM_INTERFACE_REPRESENTATION_BYTES; + for (const group of groups.values()) { + remaining -= + 512 + + group.left.reduce( + (sum, entry) => sum + wasmInterfaceEntryRepresentationBytes(entry), + 0, + ) + + group.right.reduce( + (sum, entry) => sum + wasmInterfaceEntryRepresentationBytes(entry), + 0, + ); + if (remaining < 0) + throw new AnalysisResourceConstraintError( + OPERATION, + "capacity", + "WASM comparison exceeds typed interface representation capacity", + { maximum_bytes: WASM_INTERFACE_REPRESENTATION_BYTES }, + ); + if (++work % 256 === 0) { + await setImmediate(); + options?.signal?.throwIfAborted(); + } + let state: keyof typeof summary; + let reason: string | null = null; + const l = group.left[0]; + const r = group.right[0]; + if (group.left.length > 1 || group.right.length > 1) { + state = "unknown"; + reason = + "Duplicate interface names are ambiguous; all occurrences retained"; + } else if (!knownInventory(left) || !knownInventory(right)) { + state = "unknown"; + reason = + "Incomplete interface inventories cannot establish per-key uniqueness or absence"; + } else if ( + [l, r].some( + (entry) => + entry !== undefined && + (entry.signature === null || + entry.issue !== null || + entry.kind === "unknown"), + ) + ) { + state = "unknown"; + reason = + "Signature/type semantics were not admitted for every present entry"; + } else if (l === undefined) state = "added"; + else if (r === undefined) state = "removed"; + else if (l.kind !== r.kind) state = "changed"; + else + state = + JSON.stringify(l.signature) === JSON.stringify(r.signature) + ? "unchanged" + : "changed"; + summary[state]++; + changes.push({ + direction: group.entry.direction, + module: group.entry.module, + name: group.entry.name, + state, + left: group.left, + right: group.right, + reason, + }); + } + const compareText = (a: string, b: string): number => + a < b ? -1 : a > b ? 1 : 0; + changes.sort( + (a, b) => + compareText(a.direction, b.direction) || + compareText(a.module ?? "", b.module ?? "") || + compareText(a.name, b.name), + ); + const coverage = + left.result.interface === undefined && + right.result.interface === undefined + ? "unknown" + : !knownInventory(left) || !knownInventory(right) || summary.unknown > 0 + ? "partial" + : "complete-within-inputs"; + const limitations = [ + "Exact declared interface equality is not runtime/ABI compatibility, successful linking or original-source identity.", + "Typed facts come from the selected validated WABT decoding; unsupported signature semantics and duplicate names remain unknown.", + ]; + const report = wasmInterfaceComparisonSchema.parse({ + left: { + evidence_id: left.evidence.evidence_id, + artifact: left.result.artifact, + }, + right: { + evidence_id: right.evidence.evidence_id, + artifact: right.result.artifact, + }, + scope, + coverage, + changes, + summary, + limitations, + }); + await setImmediate(); + options?.signal?.throwIfAborted(); + return ok( + createEvidence(undefined, ARTIFACT_COMPARISON_PROVIDER, { + operation: OPERATION, + predicateType: "rea.wasm-interface-comparison", + parameters: { + left_evidence_id: left.evidence.evidence_id, + right_evidence_id: right.evidence.evidence_id, + scope, + }, + result: report, + confidence: "derived", + authority: "analyst-inference", + limitations, + evidenceLinks: [left.evidence.evidence_id, right.evidence.evidence_id], + }), + ); + } catch (cause: unknown) { + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); + if (cause instanceof AnalysisError) return err(cause); + return err( + new AnalysisInputError(OPERATION, { cause }, [ + { + path: [], + reason: "invalid_value", + message: cause instanceof Error ? cause.message : String(cause), + }, + ]), + ); + } +}; diff --git a/src/cli/wasmCommands.ts b/src/cli/wasmCommands.ts index b91bda033..73b8f8d3f 100644 --- a/src/cli/wasmCommands.ts +++ b/src/cli/wasmCommands.ts @@ -1,3 +1,6 @@ +import { compareWasmInterfaces } from "../application/wasm/WasmInterfaceComparisonService.js"; +import { parseCliJsonInput } from "../cliJsonInput.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; import { z } from "incur"; import { resolve } from "node:path"; import { wasmArtifactModule } from "../composition/wasm.js"; @@ -13,6 +16,29 @@ export const registerWasmCommands = ( logger: Logger, environment: Readonly, ): void => { + cli.command(CLI_COMMANDS.compareWasmInterfaces, { + description: + "Compare core WASM interfaces from two exact inspection Evidence records", + args: z.object({ + input: z + .string() + .describe( + "Inline JSON or input file containing left/right complete Evidence and required scope", + ), + }), + run: ({ args }) => + withCommandCancellation(async (signal) => + logCliCommand(logger, CLI_COMMANDS.compareWasmInterfaces, async () => { + const input = await parseCliJsonInput( + args.input, + "compare_wasm_interfaces", + ); + if (!input.ok) return input.error; + const result = await compareWasmInterfaces(input.value, { signal }); + return result.ok ? result.value : projectAnalysisError(result.error); + }), + ), + }); const service = wasmArtifactModule.create(environment); cli.command(wasmArtifactModule.cliCommand, { description: "Inspect an explicit local WASM artifact with WABT", diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index b3f2cb0a6..ff208c62e 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -4,6 +4,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectPacketCapture: "inspect-packet-capture", inspectSignalDecoders: "inspect-signal-decoders", decodeSignalRecording: "decode-signal-recording", + compareWasmInterfaces: "compare-wasm-interfaces", inspectWasmArtifact: "inspect-wasm-artifact", inspectSqliteDatabase: "inspect-sqlite-database", inspectEvmInterface: "inspect-evm-interface", diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index fe18c96b3..3a4acca99 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -27,6 +27,8 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { inspect_macho: "runs host Mach-O inspection tools, not the target", inspect_evm_interface: "decodes bytecode in REA's bundled worker without executing it", + compare_wasm_interfaces: + "compares retained inspection Evidence without executing modules", inspect_wasm_artifact: "WABT validates and decompiles the module without executing it", identify_flutter_build: diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 939f123eb..377905480 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -623,6 +623,7 @@ export const TOOL_EFFECTS: Readonly> = { }), compare_process_captures: sessionEvidence, compare_artifacts: sessionEvidence, + compare_wasm_interfaces: sessionEvidence, compare_functions: sessionEvidence, compare_bundles: effects({ mutatesSession: true }), find_changed_behavior: sessionEvidence, diff --git a/src/contracts/wasm/wasmComparisonContracts.ts b/src/contracts/wasm/wasmComparisonContracts.ts new file mode 100644 index 000000000..7eae508b7 --- /dev/null +++ b/src/contracts/wasm/wasmComparisonContracts.ts @@ -0,0 +1,63 @@ +import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; +import { z } from "zod"; +import { evidenceInputSchema } from "../evidenceInputContracts.js"; +import { wasmInterfaceSchema } from "../../domain/wasm/wasmInterface.js"; +import { toolContractMetadata } from "../toolEffects.js"; +import type { ToolContract } from "../toolContractTypes.js"; + +export const compareWasmInterfacesInputSchema = z.strictObject({ + left: evidenceInputSchema.describe( + "Exact complete inspect_wasm_artifact Evidence or retained session reference", + ), + right: evidenceInputSchema.describe( + "Exact complete inspect_wasm_artifact Evidence or retained session reference", + ), + scope: z + .enum(["imports", "exports", "both"]) + .describe( + "Explicit interface selection; static equality does not establish runtime compatibility", + ), +}); +const identity = z.object({ + evidence_id: z.string(), + artifact: z.object({ + path: z.string(), + sha256: z.string(), + bytes: z.number().int().nonnegative(), + }), +}); +export const wasmInterfaceComparisonSchema = z.object({ + left: identity, + right: identity, + scope: z.enum(["imports", "exports", "both"]), + coverage: z.enum(["complete-within-inputs", "partial", "unknown"]), + changes: z.array( + z.object({ + direction: z.enum(["import", "export"]), + module: z.string().nullable(), + name: z.string(), + state: z.enum(["added", "removed", "changed", "unchanged", "unknown"]), + left: wasmInterfaceSchema.shape.entries, + right: wasmInterfaceSchema.shape.entries, + reason: z.string().nullable(), + }), + ), + summary: z.object({ + added: z.number(), + removed: z.number(), + changed: z.number(), + unchanged: z.number(), + unknown: z.number(), + }), + limitations: z.array(z.string()), +}); +export const COMPARE_WASM_INTERFACES_CONTRACT = { + name: "compare_wasm_interfaces", + ...toolContractMetadata("compare_wasm_interfaces"), + kind: "application", + description: + "Compare typed core WASM import/export names, kinds and exact signatures from two authenticated inspect_wasm_artifact Evidence records. Select imports, exports or both explicitly. Retained references resolve only in the current session; CLI accepts portable inline Evidence. Duplicate names, unsupported types and missing historical interface facets remain unknown. Preserve original module digests and every occurrence; no source acquisition, module execution or runtime-compatibility inference.", + inputSchema: compareWasmInterfacesInputSchema, + outputSchema: evidenceResultOf(wasmInterfaceComparisonSchema), + examples: [], +} as const satisfies ToolContract; diff --git a/src/contracts/wasm/wasmToolContracts.ts b/src/contracts/wasm/wasmToolContracts.ts index 34f26f11d..cf05166d2 100644 --- a/src/contracts/wasm/wasmToolContracts.ts +++ b/src/contracts/wasm/wasmToolContracts.ts @@ -1,3 +1,4 @@ +import { COMPARE_WASM_INTERFACES_CONTRACT } from "./wasmComparisonContracts.js"; import { inspectWasmArtifactInputSchema, wasmArtifactSchema, @@ -40,4 +41,7 @@ export const INSPECT_WASM_ARTIFACT_CONTRACT = { } as const satisfies ToolContract; /** Keep the public catalog in its existing order, using its named contract. */ -export const WASM_TOOL_CONTRACTS = [INSPECT_WASM_ARTIFACT_CONTRACT] as const; +export const WASM_TOOL_CONTRACTS = [ + INSPECT_WASM_ARTIFACT_CONTRACT, + COMPARE_WASM_INTERFACES_CONTRACT, +] as const; diff --git a/src/domain/wasm/wasmArtifact.ts b/src/domain/wasm/wasmArtifact.ts index d8c89ddf3..a15735125 100644 --- a/src/domain/wasm/wasmArtifact.ts +++ b/src/domain/wasm/wasmArtifact.ts @@ -1,3 +1,4 @@ +import { wasmInterfaceSchema } from "./wasmInterface.js"; import { z } from "zod"; import { createHash } from "node:crypto"; import type { EvidenceSubjectTarget } from "../evidence.js"; @@ -32,6 +33,7 @@ export const wasmArtifactSchema = z description: z.string(), }), ), + interface: wasmInterfaceSchema.exactOptional(), imports: z.array(z.string()), exports: z.array(z.string()), headers: z.string(), diff --git a/src/domain/wasm/wasmInterface.ts b/src/domain/wasm/wasmInterface.ts new file mode 100644 index 000000000..f5559269c --- /dev/null +++ b/src/domain/wasm/wasmInterface.ts @@ -0,0 +1,109 @@ +import { z } from "zod"; + +const kind = z.enum(["func", "table", "memory", "global", "tag", "unknown"]); +const valueType = z.enum([ + "i32", + "i64", + "f32", + "f64", + "v128", + "funcref", + "externref", +]); +const limit = z.string().regex(/^(?:0|[1-9][0-9]*)$/u); +const signature = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("func"), + params: z.array(valueType), + results: z.array(valueType), + }), + z.object({ + kind: z.literal("tag"), + params: z.array(valueType), + results: z.array(valueType), + }), + z.object({ + kind: z.literal("table"), + element: z.enum(["funcref", "externref"]), + address_type: z.enum(["i32", "i64"]), + minimum: limit, + maximum: limit.nullable(), + shared: z.boolean(), + }), + z.object({ + kind: z.literal("memory"), + page_size: limit, + address_type: z.enum(["i32", "i64"]), + minimum: limit, + maximum: limit.nullable(), + shared: z.boolean(), + }), + z.object({ + kind: z.literal("global"), + value_type: valueType, + mutable: z.boolean(), + }), +]); + +/** Typed observations decoded once at the WABT producer boundary. */ +export const wasmInterfaceSchema = z.object({ + state: z.enum(["complete", "partial"]), + entries: z.array( + z + .object({ + direction: z.enum(["import", "export"]), + module: z.string().nullable(), + name: z.string(), + kind, + index: z.number().int().nonnegative().max(0xffff_ffff).nullable(), + signature: signature.nullable(), + wat_form: z.string(), + issue: z.string().nullable(), + }) + .superRefine((entry, context) => { + if ( + (entry.direction === "export" && entry.module !== null) || + (entry.direction === "import" && entry.module === null) + ) + context.addIssue({ + code: "custom", + message: "Interface direction does not match module-name presence", + }); + if (entry.signature !== null && entry.signature.kind !== entry.kind) + context.addIssue({ + code: "custom", + message: "Interface kind does not match its typed signature", + }); + }), + ), + issues: z.array(z.string()), +}); +export type WasmInterface = z.infer; +export type WasmInterfaceEntry = WasmInterface["entries"][number]; +export type WasmInterfaceSignature = NonNullable< + WasmInterfaceEntry["signature"] +>; + +export type WasmInterfaceValueType = z.infer; + +/** Bound eager typed output and repeated signature expansion independently of WAT bytes. */ +export const WASM_INTERFACE_REPRESENTATION_BYTES = 32 * 1024 * 1024; + +/** Conservative JSON/retention charge; shared arrays still expand at every occurrence. */ +export const wasmInterfaceEntryRepresentationBytes = ( + entry: WasmInterfaceEntry, +): number => { + let bytes = + 1024 + + [entry.module, entry.name, entry.wat_form, entry.issue].reduce( + (sum, value) => sum + (value?.length ?? 0) * 6, + 0, + ); + const signature = entry.signature; + if (signature?.kind === "func" || signature?.kind === "tag") + bytes += (signature.params.length + signature.results.length) * 16; + else if (signature !== null) + for (const value of Object.values(signature)) + if (typeof value === "string") bytes += value.length * 6; + return bytes; +}; diff --git a/src/server/registerWasmTools.ts b/src/server/registerWasmTools.ts index 494972b2f..a7a056cc7 100644 --- a/src/server/registerWasmTools.ts +++ b/src/server/registerWasmTools.ts @@ -1,3 +1,7 @@ +import { compareWasmInterfaces } from "../application/wasm/WasmInterfaceComparisonService.js"; +import { COMPARE_WASM_INTERFACES_CONTRACT } from "../contracts/wasm/wasmComparisonContracts.js"; +import { resolvePairedEvidenceRequest } from "../application/EvidenceInputResolver.js"; +import { recordSessionEvidenceSources } from "./sessionEvidence.js"; import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import type { WasmArtifactService } from "../application/wasm/WasmArtifactService.js"; @@ -21,6 +25,38 @@ export const registerWasmTools = ( withAdmittedAnalysis?: WithAdmittedAnalysis, evidenceById?: EvidenceLookup, ): void => { + const comparison = COMPARE_WASM_INTERFACES_CONTRACT; + const compareHandler = createEvidenceToolHandler( + server, + async (rawInput, options) => { + const input = comparison.inputSchema.safeParse(rawInput); + if (!input.success) + return err( + analysisInputErrorFromIssues( + comparison.name, + input.error.issues, + rawInput, + ), + ); + const sources = resolvePairedEvidenceRequest(input.data, evidenceById); + if (!sources.ok) return sources; + const result = await compareWasmInterfaces(sources.value, options); + if (!result.ok) return result; + const recorded = recordSessionEvidenceSources(recordEvidence, [ + sources.value.left, + sources.value.right, + ]); + return recorded.ok ? result : recorded; + }, + logger, + recordEvidence, + withAdmittedAnalysis, + ); + server.registerTool( + comparison.name, + toolRegistrationOptions(comparison), + compareHandler(comparison), + ); const contract = wasmArtifactModule.contract; const handler = createEvidenceToolHandler( server, diff --git a/src/wasm/WabtArtifactProvider.ts b/src/wasm/WabtArtifactProvider.ts index c5f0aef53..3be21b2a1 100644 --- a/src/wasm/WabtArtifactProvider.ts +++ b/src/wasm/WabtArtifactProvider.ts @@ -362,8 +362,13 @@ export class WabtArtifactProvider implements WasmArtifactPort { OPERATION, "Unexpected WABT decoding output.", ); - const { imports, exports, customSectionNames, debugModuleNames } = - parseWabtWat(wat.stdout.text); + const { + imports, + exports, + customSectionNames, + debugModuleNames, + interface: interfaceFacts, + } = parseWabtWat(wat.stdout.text); capturedOutput = { stdout: dump.stdout.text, stderr: dump.stderr.text, @@ -388,6 +393,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { sections, imports, exports, + interface: interfaceFacts, headers, details, wat: { diff --git a/src/wasm/WabtInterface.test.ts b/src/wasm/WabtInterface.test.ts new file mode 100644 index 000000000..68505f3ba --- /dev/null +++ b/src/wasm/WabtInterface.test.ts @@ -0,0 +1,116 @@ +import { expect, it } from "vitest"; +import { parseWabtWat } from "./WabtWat.js"; + +it("resolves imported index spaces and decoded escaped names without depending on body/type numbering", () => { + const report = parseWabtWat(`(module + (type (;0;) (func (param i32) (result i64))) + (import "a\\22b" "\\ef\\bb\\bf\\00" (func (;0;) (type 0))) + (func (;1;) (type 0) (param i32) (result i64) local.get 0 i64.extend_i32_s) + (export "" (func 1)))`).interface; + expect(report.entries).toMatchObject([ + { + direction: "import", + module: 'a"b', + name: "\ufeff\0", + index: 0, + signature: { kind: "func", params: ["i32"], results: ["i64"] }, + }, + { + direction: "export", + name: "", + index: 1, + signature: { kind: "func", params: ["i32"], results: ["i64"] }, + }, + ]); +}); + +it("retains exact memory/table/global/tag declarations and keeps unsupported references unknown", () => { + const report = parseWabtWat(`(module + (type (func (param i32))) + (type (struct (field i32))) + (import "env" "table" (table 1 3 funcref)) + (memory i64 2 4 shared) + (global (mut i64) (i64.const 1)) + (tag (type 0)) + (func (type 0)) + (func (param (ref null 1))) + (export "memory" (memory 0)) (export "global" (global 0)) + (export "tag" (tag 0)) (export "gc" (func 1)))`).interface; + expect(report.entries.map((e) => e.signature)).toEqual([ + { + kind: "table", + element: "funcref", + address_type: "i32", + minimum: "1", + maximum: "3", + shared: false, + }, + { + kind: "memory", + page_size: "65536", + address_type: "i64", + minimum: "2", + maximum: "4", + shared: true, + }, + { kind: "global", value_type: "i64", mutable: true }, + { kind: "tag", params: ["i32"], results: [] }, + null, + ]); + expect(report.entries[4]?.issue).toBeTruthy(); +}); + +it("does not bind recursive-type index spaces by guessing their cardinality", () => { + const report = parseWabtWat( + `(module (rec (type (struct)) (type (func))) (func (type 1)) (export "f" (func 0)))`, + ).interface; + expect(report.entries).toMatchObject([ + { name: "f", signature: null, issue: expect.any(String) }, + ]); +}); + +it("preserves custom memory page sizes and treats unadmitted nested declarations as unknown", () => { + const custom = parseWabtWat( + '(module (memory 1 2 (pagesize 1)) (export "m" (memory 0)))', + ).interface; + expect(custom.entries[0]?.signature).toMatchObject({ + kind: "memory", + page_size: "1", + }); + const unknown = parseWabtWat( + '(module (memory 1 (future-proposal 0)) (export "m" (memory 0)))', + ).interface; + expect(unknown.entries[0]?.signature).toBeNull(); +}); + +it("refuses typed name expansion within the source WABT byte cap before retaining interface output", () => { + const name = "x".repeat(6 * 1024 * 1024); + expect( + parseWabtWat(`(module (import "${name}" "f" (func)))`).interface, + ).toMatchObject({ + state: "partial", + entries: [], + issues: [expect.stringMatching(/representation budget/u)], + }); +}); + +it("charges shared type signatures at every retained occurrence and preserves bounded partial facts", () => { + const params = Array(1000).fill("i32").join(" "); + const exports = Array.from( + { length: 10000 }, + (_, i) => `(export "f${i}" (func 0))`, + ).join(" "); + const report = parseWabtWat( + `(module (type (func (param ${params}))) (import "e" "f" (func (type 0))) ${exports})`, + ).interface; + expect(report.state).toBe("partial"); + expect(report.entries.length).toBeGreaterThan(1); + expect(report.entries.length).toBeLessThan(10001); + expect(report.entries[0]?.signature).toMatchObject({ + kind: "func", + params: expect.any(Array), + }); + expect(Buffer.byteLength(JSON.stringify(report))).toBeLessThan( + 32 * 1024 * 1024, + ); +}); diff --git a/src/wasm/WabtInterface.ts b/src/wasm/WabtInterface.ts new file mode 100644 index 000000000..65e6fc0fa --- /dev/null +++ b/src/wasm/WabtInterface.ts @@ -0,0 +1,325 @@ +import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; +import { wasmInterfaceEntryRepresentationBytes } from "../domain/wasm/wasmInterface.js"; +import { WasmInterfaceBudget } from "./WasmInterfaceBudget.js"; +import type { + WasmInterface, + WasmInterfaceEntry, + WasmInterfaceSignature, + WasmInterfaceValueType, +} from "../domain/wasm/wasmInterface.js"; +import { watString } from "./WabtString.js"; + +/** Split direct WAT children only; function instruction bodies are never materialized. */ +const parts = (form: string): string[] => { + const budget = new WasmInterfaceBudget(); + const items: string[] = []; + let i = 1; + while (i < form.length - 1) { + if (/\s/u.test(form[i] ?? "")) { + i++; + continue; + } + if (form.startsWith("(;", i)) { + let depth = 1; + i += 2; + while (i < form.length && depth > 0) { + if (form.startsWith("(;", i)) { + depth++; + i += 2; + } else if (form.startsWith(";)", i)) { + depth--; + i += 2; + } else i++; + } + continue; + } + if (form.startsWith(";;", i)) { + const end = form.indexOf("\n", i); + i = end < 0 ? form.length : end + 1; + continue; + } + const start = i; + if (form[i] === '"') { + i = watString(form, i + 1).end; + } else if (form[i] === "(") { + let depth = 1; + i++; + while (i < form.length && depth > 0) { + if (form[i] === '"') i = watString(form, i + 1).end; + else { + if (form[i] === "(") depth++; + else if (form[i] === ")") depth--; + i++; + } + } + } else { + while (i < form.length && !/[\s()]/u.test(form[i] ?? "")) i++; + const atom = form.slice(start, i); + if (items[0] === "func" && items.length > 1 && !atom.startsWith("$")) + break; + } + if (i <= start) throw new Error("Malformed WABT form"); + budget.reserve(64 + (i - start) * 2); + items.push(form.slice(start, i)); + } + return items; +}; +const head = (form: string): string | undefined => parts(form)[0]; +const admittedValues = new Set([ + "i32", + "i64", + "f32", + "f64", + "v128", + "funcref", + "externref", +]); +const isValueType = (type: string): type is WasmInterfaceValueType => + admittedValues.has(type); +const values = ( + form: string, + category: string, +): WasmInterfaceValueType[] | null => { + const result: WasmInterfaceValueType[] = []; + for (const item of parts(form)) { + if (!item.startsWith("(")) continue; + const group = parts(item); + if (group[0] !== category) continue; + for (const type of group.slice(1).filter((t) => !t.startsWith("$"))) { + if (!isValueType(type)) return null; + result.push(type); + } + } + return result; +}; +const functionType = ( + form: string, + kind: "func" | "tag", +): WasmInterfaceSignature | null => { + const params = values(form, "param"); + const results = values(form, "result"); + return params === null || results === null ? null : { kind, params, results }; +}; +const name = (text: string | undefined): string => { + if (text?.startsWith('"') !== true) + throw new Error("WABT interface name is missing"); + return new TextDecoder("utf8", { fatal: true, ignoreBOM: true }).decode( + watString(text, 1).bytes, + ); +}; +const integer = (text: string): string | null => { + if (!/^(?:\d+|0x[\da-f]+)$/iu.test(text)) return null; + return BigInt(text).toString(); +}; + +/** Resolve index spaces at the producer, not from retained raw WAT in consumers. */ +const decodeWabtInterface = ( + forms: readonly string[], + result: WasmInterface, +): WasmInterface => { + const budget = new WasmInterfaceBudget(); + const types: (WasmInterfaceSignature | null)[] = []; + const typeNames = new Map(); + const definitions = new Map< + string, + { signature: WasmInterfaceSignature | null; label: string | undefined }[] + >(); + const typeSpaceUnknown = forms.some((f) => head(f) === "rec"); + for (const form of forms) { + const p = parts(form); + if (p[0] !== "type") continue; + if (p[1]?.startsWith("$") === true) typeNames.set(p[1], types.length); + const body = p.find((item) => item.startsWith("(")); + budget.reserve(256 + (body?.length ?? 0) * 6); + types.push( + body !== undefined && head(body) === "func" && !typeSpaceUnknown + ? functionType(body, "func") + : null, + ); + } + const signature = (form: string): WasmInterfaceSignature | null => { + const p = parts(form); + const kind = p[0]; + if (kind === "func" || kind === "tag") { + const use = p.find((t) => t.startsWith("(type ")); + if (use === undefined) return functionType(form, kind); + const reference = parts(use)[1]; + const index = + reference === undefined + ? undefined + : reference.startsWith("$") + ? typeNames.get(reference) + : Number(reference); + const type = + index === undefined || typeSpaceUnknown ? undefined : types[index]; + return type?.kind === "func" ? { ...type, kind } : null; + } + if (kind === "memory" || kind === "table") { + const groups = p.slice(1).filter((t) => t.startsWith("(")); + let pageSize = "65536"; + for (const group of groups) { + const declaration = parts(group); + if ( + kind !== "memory" || + declaration[0] !== "pagesize" || + declaration.length !== 2 + ) + return null; + const parsedPageSize = integer(declaration[1] ?? ""); + if (parsedPageSize === null) return null; + pageSize = parsedPageSize; + } + const tokens = p + .slice(1) + .filter((t) => !t.startsWith("$") && !t.startsWith("(")); + const address_type = tokens[0] === "i64" ? "i64" : "i32"; + if (tokens[0] === "i64" || tokens[0] === "i32") tokens.shift(); + const minimum = integer(tokens.shift() ?? ""); + const possibleMaximum = + tokens[0] === undefined ? null : integer(tokens[0]); + if (possibleMaximum !== null) tokens.shift(); + const shared = tokens[0] === "shared"; + if (shared) tokens.shift(); + if (minimum === null) return null; + if (kind === "memory") + return tokens.length === 0 + ? { + kind, + page_size: pageSize, + address_type, + minimum, + maximum: possibleMaximum, + shared, + } + : null; + const element = tokens.shift(); + return tokens.length === 0 && + element !== undefined && + (element === "funcref" || element === "externref") + ? { + kind, + address_type, + minimum, + maximum: possibleMaximum, + shared, + element, + } + : null; + } + if (kind === "global") { + const declaration = p[1]?.startsWith("$") === true ? p[2] : p[1]; + const mutable = declaration?.startsWith("(mut ") === true; + const value_type = + mutable && declaration !== undefined + ? parts(declaration)[1] + : declaration; + return value_type !== undefined && isValueType(value_type) + ? { kind, value_type, mutable } + : null; + } + return null; + }; + const addDefinition = ( + form: string, + ): { + index: number; + kind: WasmInterfaceEntry["kind"]; + signature: WasmInterfaceSignature | null; + } => { + const p = parts(form); + const rawKind = p[0] ?? "unknown"; + const kind = ["func", "table", "memory", "global", "tag"].includes(rawKind) + ? (rawKind as WasmInterfaceEntry["kind"]) + : "unknown"; + const space = definitions.get(kind) ?? []; + const item = { + signature: signature(form), + label: p[1]?.startsWith("$") === true ? p[1] : undefined, + }; + budget.reserve(256); + const index = space.length; + space.push(item); + definitions.set(kind, space); + return { index, kind, signature: item.signature }; + }; + for (const form of forms) { + const p = parts(form); + if (p[0] !== "import") continue; + const body = p[3]; + if (body === undefined) throw new Error("Incomplete WABT import"); + const observed = addDefinition(body); + const entry: WasmInterfaceEntry = { + direction: "import", + module: name(p[1]), + name: name(p[2]), + ...observed, + wat_form: form, + issue: + observed.signature === null + ? "Import type is outside admitted core signature semantics" + : null, + }; + budget.reserve(wasmInterfaceEntryRepresentationBytes(entry)); + result.entries.push(entry); + } + for (const form of forms) { + const kind = head(form); + if ( + kind !== undefined && + ["func", "table", "memory", "global", "tag"].includes(kind) + ) + addDefinition(form); + } + for (const form of forms) { + const p = parts(form); + if (p[0] !== "export") continue; + const body = p[2]; + if (body === undefined) throw new Error("Incomplete WABT export"); + const descriptor = parts(body); + const rawKind = descriptor[0] ?? "unknown"; + const kind = ["func", "table", "memory", "global", "tag"].includes(rawKind) + ? (rawKind as WasmInterfaceEntry["kind"]) + : "unknown"; + const reference = descriptor[1]; + const space = definitions.get(kind) ?? []; + const index = + reference === undefined + ? -1 + : reference.startsWith("$") + ? space.findIndex((d) => d.label === reference) + : Number(reference); + const observed = + Number.isSafeInteger(index) && index >= 0 ? space[index] : undefined; + const entry: WasmInterfaceEntry = { + direction: "export", + module: null, + name: name(p[1]), + kind, + index: observed === undefined ? null : index, + signature: observed?.signature ?? null, + wat_form: form, + issue: + observed?.signature == null + ? "Export type/index is outside admitted core signature semantics" + : null, + }; + budget.reserve(wasmInterfaceEntryRepresentationBytes(entry)); + result.entries.push(entry); + } + // Unsupported signatures leave inventory completeness intact. A missing + // signature is unknown per entry, not proof that a name is absent. + return result; +}; + +/** New typed coverage may be incomplete without discarding existing decoded WAT Evidence. */ +export const parseWabtInterface = (forms: readonly string[]): WasmInterface => { + const result: WasmInterface = { state: "complete", entries: [], issues: [] }; + try { + return decodeWabtInterface(forms, result); + } catch (cause: unknown) { + if (!(cause instanceof AnalysisResourceConstraintError)) throw cause; + result.state = "partial"; + result.issues.push(cause.message); + return result; + } +}; diff --git a/src/wasm/WabtOutput.test.ts b/src/wasm/WabtOutput.test.ts index abd20d848..91740f09d 100644 --- a/src/wasm/WabtOutput.test.ts +++ b/src/wasm/WabtOutput.test.ts @@ -229,7 +229,7 @@ it("extracts escaped WAT import/export forms while retaining multiline producer new URL("../../tests/fixtures/wasm/decoded.wat", import.meta.url), "utf8", ); - expect(parseWabtWat(text)).toEqual({ + expect(parseWabtWat(text)).toMatchObject({ imports: ['(import "env" "foo" (func (;0;) (type 0)))'], exports: ['(export "memory" (memory 0))', '(export "run" (func 1))'], customSectionNames: ["test"], @@ -239,7 +239,7 @@ it("extracts escaped WAT import/export forms while retaining multiline producer parseWabtWat( '(module (import "a.b" "c\\0aline" (func)) (export "fake\\22Export[1]:" (func 0)))', ), - ).toEqual({ + ).toMatchObject({ imports: ['(import "a.b" "c\\0aline" (func))'], exports: ['(export "fake\\22Export[1]:" (func 0))'], customSectionNames: [], diff --git a/src/wasm/WabtString.ts b/src/wasm/WabtString.ts new file mode 100644 index 000000000..79615b193 --- /dev/null +++ b/src/wasm/WabtString.ts @@ -0,0 +1,46 @@ +/** Decode a producer WAT string to frame objdump's raw display text. */ +export const watString = (form: string, start: number) => { + let end = start; + while (end < form.length && form[end] !== '"') { + if (form[end] === "\\") end++; + end++; + } + if (end === form.length) + throw new Error("Incomplete WABT custom-section name."); + const name = form.slice(start, end); + // Escapes shrink; literal UTF-8 has the same byte extent as its source. + const bytes = Buffer.alloc(Buffer.byteLength(name, "utf8")); + let length = 0; + for (let index = 0; index < name.length;) { + if (name[index] !== "\\") { + const next = name.indexOf("\\", index); + const end = next < 0 ? name.length : next; + length += bytes.write(name.slice(index, end), length, "utf8"); + index = end; + continue; + } + const hex = name.slice(index + 1, index + 3); + if (/^[\da-f]{2}$/iu.test(hex)) { + bytes[length++] = Number.parseInt(hex, 16); + index += 3; + } else { + const escape = name[index + 1]; + const value = + escape === "t" + ? 9 + : escape === "n" + ? 10 + : escape === "r" + ? 13 + : escape === '"' + ? 34 + : escape === "\\" + ? 92 + : undefined; + if (value === undefined) throw new Error("Unsupported WABT name escape."); + bytes[length++] = value; + index += 2; + } + } + return { bytes: bytes.subarray(0, length), end: end + 1 }; +}; diff --git a/src/wasm/WabtWat.ts b/src/wasm/WabtWat.ts index d766fe617..01b9f16bc 100644 --- a/src/wasm/WabtWat.ts +++ b/src/wasm/WabtWat.ts @@ -1,49 +1,5 @@ -/** Decode a producer WAT string to frame objdump's raw display text. */ -const watString = (form: string, start: number) => { - let end = start; - while (end < form.length && form[end] !== '"') { - if (form[end] === "\\") end++; - end++; - } - if (end === form.length) - throw new Error("Incomplete WABT custom-section name."); - const name = form.slice(start, end); - // Escapes shrink; literal UTF-8 has the same byte extent as its source. - const bytes = Buffer.alloc(Buffer.byteLength(name, "utf8")); - let length = 0; - for (let index = 0; index < name.length;) { - if (name[index] !== "\\") { - const next = name.indexOf("\\", index); - const end = next < 0 ? name.length : next; - length += bytes.write(name.slice(index, end), length, "utf8"); - index = end; - continue; - } - const hex = name.slice(index + 1, index + 3); - if (/^[\da-f]{2}$/iu.test(hex)) { - bytes[length++] = Number.parseInt(hex, 16); - index += 3; - } else { - const escape = name[index + 1]; - const value = - escape === "t" - ? 9 - : escape === "n" - ? 10 - : escape === "r" - ? 13 - : escape === '"' - ? 34 - : escape === "\\" - ? 92 - : undefined; - if (value === undefined) throw new Error("Unsupported WABT name escape."); - bytes[length++] = value; - index += 2; - } - } - return { bytes: bytes.subarray(0, length), end: end + 1 }; -}; +import { parseWabtInterface } from "./WabtInterface.js"; +import { watString } from "./WabtString.js"; /** Read only module-name metadata from WABT's preserved name-section payload. */ const moduleNames = (bytes: Uint8Array): string[] => { @@ -85,6 +41,7 @@ const moduleNames = (bytes: Uint8Array): string[] => { export const parseWabtWat = (text: string) => { if (!/^\(module(?:\s|\))/u.test(text)) throw new Error("Unsupported WABT WAT module prefix."); + const forms: string[] = []; const imports: string[] = []; const exports: string[] = []; const customSectionNames: string[] = []; @@ -140,6 +97,12 @@ export const parseWabtWat = (text: string) => { } else if (char === ")") { if (depth === 2) { const form = text.slice(start, index + 1); + if ( + /^\((?:type|rec|import|export|func|table|memory|global|tag)\s/u.test( + form, + ) + ) + forms.push(form); if (/^\(import\s/u.test(form)) imports.push(form); else if (/^\(export\s/u.test(form)) exports.push(form); else if (/^\(@custom\s/u.test(form)) { @@ -172,5 +135,11 @@ export const parseWabtWat = (text: string) => { } if (depth !== 0 || quoted || commentDepth !== 0) throw new Error("Incomplete WABT WAT output."); - return { imports, exports, customSectionNames, debugModuleNames }; + return { + imports, + exports, + customSectionNames, + debugModuleNames, + interface: parseWabtInterface(forms), + }; }; diff --git a/src/wasm/WasmInterfaceBudget.ts b/src/wasm/WasmInterfaceBudget.ts new file mode 100644 index 000000000..0d928b7ff --- /dev/null +++ b/src/wasm/WasmInterfaceBudget.ts @@ -0,0 +1,17 @@ +import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; +import { WASM_INTERFACE_REPRESENTATION_BYTES } from "../domain/wasm/wasmInterface.js"; + +/** Eager typed observations share the existing WABT output capacity, not an agent token estimate. */ +export class WasmInterfaceBudget { + #remaining = WASM_INTERFACE_REPRESENTATION_BYTES; + reserve(bytes: number): void { + if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining) + throw new AnalysisResourceConstraintError( + "inspect_wasm_artifact", + "capacity", + "Typed WASM interface decoding exceeds the WABT output representation budget", + { maximum_bytes: WASM_INTERFACE_REPRESENTATION_BYTES }, + ); + this.#remaining -= bytes; + } +} diff --git a/tests/acceptance/wasmInterfaceComparison.test.ts b/tests/acceptance/wasmInterfaceComparison.test.ts new file mode 100644 index 000000000..77f6c4319 --- /dev/null +++ b/tests/acceptance/wasmInterfaceComparison.test.ts @@ -0,0 +1,130 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect } from "vitest"; +import { createEvidence, parseEvidence } from "../../src/domain/evidence.js"; +import { wasmInterfaceComparisonSchema } from "../../src/contracts/wasm/wasmComparisonContracts.js"; +import { composeBinarySession } from "../../src/application/binary/BinarySessionComposition.js"; +import { AnalysisProviderRegistry } from "../../src/application/binary/AnalysisProviderRegistry.js"; +import { createServer } from "../../src/server/createServer.js"; +import { wasmArtifactFixture } from "../fixtures/wasm/artifact.js"; +import { cliTest } from "../support/cli/cliFixture.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +cliTest( + "compares portable Evidence identically through CLI and retained MCP with schema/provenance", + async ({ cli }) => { + const sources = (["i32", "i64"] as const).map((parameter) => { + const report = wasmArtifactFixture(); + report.interface = { + state: "complete", + issues: [], + entries: [ + { + direction: "export", + module: null, + name: 'quoted"\ufeffname', + kind: "func", + index: 0, + signature: { kind: "func", params: [parameter], results: [] }, + wat_form: '(export "quoted\\22\\ef\\bb\\bfname" (func 0))', + issue: null, + }, + ], + }; + 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: report.artifact.path }, + result: report, + confidence: "observed", + }, + ); + }); + const [left, right] = sources; + if (left === undefined || right === undefined) + throw new Error("Missing paired fixture"); + const root = await createTestTempDirectory("rea-wasm-comparison-"); + const path = join(root, "input.json"); + const input = { left, right, scope: "both" }; + await writeFile(path, JSON.stringify(input)); + const cliResult = await cli.run({ + arguments: ["compare-wasm-interfaces", path, "--json"], + }); + expect(cliResult.exitCode).toBe(0); + const cliEvidence = parseEvidence(cliResult.json); + const cliReport = wasmInterfaceComparisonSchema.parse( + cliEvidence.normalized_result, + ); + expect(cliReport).toMatchObject({ + coverage: "complete-within-inputs", + changes: [{ name: 'quoted"\ufeffname', state: "changed" }], + left: { + evidence_id: left.evidence_id, + artifact: { + path: "/artifacts/module.wasm", + sha256: "a".repeat(64), + bytes: 8, + }, + }, + }); + const session = composeBinarySession(new AnalysisProviderRegistry([])); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "wasm-interface-parity", version: "1" }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + try { + await server.connect(st); + await client.connect(ct); + const tool = (await client.listTools()).tools.find( + (t) => t.name === "compare_wasm_interfaces", + ); + if (tool?.outputSchema === undefined) + throw new Error("Missing comparison schema"); + const outputSchema: Record = tool.outputSchema; + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + expect(ajv.validate(tool.inputSchema, input)).toBe(true); + expect(ajv.validate(tool.inputSchema, { left, right })).toBe(false); + const mcp = await client.callTool({ name: tool.name, arguments: input }); + expect(mcp.isError, JSON.stringify(mcp)).not.toBe(true); + expect( + ajv.validate(outputSchema, mcp.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const mcpEvidence = parseEvidence(mcp.structuredContent); + expect(mcpEvidence.normalized_result).toEqual(cliReport); + expect(mcpEvidence.evidence_id).toBe(cliEvidence.evidence_id); + expect(mcpEvidence.evidence_links).toEqual([ + left.evidence_id, + right.evidence_id, + ]); + const retained = await client.callTool({ + name: tool.name, + arguments: { + scope: "both", + left: { kind: "retained-evidence", evidence_id: left.evidence_id }, + right: { kind: "retained-evidence", evidence_id: right.evidence_id }, + }, + }); + expect(retained.isError).not.toBe(true); + expect(parseEvidence(retained.structuredContent).evidence_id).toBe( + mcpEvidence.evidence_id, + ); + const invalid = await client.callTool({ + name: tool.name, + arguments: { ...input, scope: "all" }, + }); + expect(invalid.isError).toBe(true); + } finally { + await client.close(); + await server.close(); + await session.close(); + } + }, +);