From 168bfe50604b3a435c05f2db9f8a0cf47230c660 Mon Sep 17 00:00:00 2001 From: SII-YDD <38822679+SII-YDD@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:49:33 +0800 Subject: [PATCH] fix(target): probe WASM before inferred database routing (#2007) * fix(target): probe WASM before inferred database routing * test(wasm): execute recovery arguments from native refusal * test(ci): repair fixture alignment and share MCP assertions --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/wasm-artifacts.md | 5 + src/application/BinaryTargetResolver.ts | 7 +- .../filesystem/targetDiagnostics.test.ts | 69 ++++++- tests/boundary/mcp/analysisViewMcp.test.ts | 13 +- .../boundary/mcp/binaryDiagnosticsMcp.test.ts | 38 ++-- tests/boundary/mcp/evmInterfaceMcp.test.ts | 36 ++-- tests/boundary/mcp/wasmArtifactMcp.test.ts | 192 +++++++++--------- tests/fixtures/advertisedMcpTool.ts | 52 +++++ 8 files changed, 261 insertions(+), 151 deletions(-) create mode 100644 tests/fixtures/advertisedMcpTool.ts diff --git a/docs/wasm-artifacts.md b/docs/wasm-artifacts.md index 364096251d..17b6c77f91 100644 --- a/docs/wasm-artifacts.md +++ b/docs/wasm-artifacts.md @@ -13,6 +13,11 @@ Check `binary_session` for configured capability availability. A recognized header is not validation of the complete module; missing WABT is reported separately as an unavailable capability, not as missing native engine support. +The recognized WASM header is checked before automatic database routing from a +.hop filename. Renaming a module to .hop therefore keeps the static-inspection +diagnostic. An explicit database selection or raw/COM format selection retains +the caller's requested interpretation; ordinary .hop database routing is unchanged. + Set `REA_WABT_BIN_DIRECTORY` to an absolute directory containing executable `wasm-validate`, `wasm-objdump` and `wasm2wat` (with `.exe` on Windows). Missing executables, unsupported banners and inaccessible tools produce actionable diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 6f1bc43668..b07364adf2 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -162,6 +162,12 @@ export const parseBinaryTarget = async ( availableArchitectures: ["x86"], }); } + // Probe WASM content before suffix-only database routing; an explicit + // database selection keeps the caller's chosen interpretation. + const artifactFormat = + targetKind === "database" + ? undefined + : await detectArtifactFormat(path, handle, signal); if ( targetKind === "database" || (targetKind === undefined && path.toLowerCase().endsWith(".hop")) @@ -173,7 +179,6 @@ export const parseBinaryTarget = async ( kind: "database", format: "analysis-database", }); - const artifactFormat = await detectArtifactFormat(path, handle, signal); if (artifactFormat !== undefined) { const identity = { path, diff --git a/tests/boundary/filesystem/targetDiagnostics.test.ts b/tests/boundary/filesystem/targetDiagnostics.test.ts index dacd216875..998657082a 100644 --- a/tests/boundary/filesystem/targetDiagnostics.test.ts +++ b/tests/boundary/filesystem/targetDiagnostics.test.ts @@ -1,4 +1,4 @@ -import { chmod, realpath, writeFile } from "node:fs/promises"; +import { chmod, readFile, realpath, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { systemDoctorHost } from "../../../src/application/Doctor.js"; @@ -78,7 +78,7 @@ describe("executable format refusal diagnostics", () => { }); describe("target route diagnostics", () => { - it.each(["module.wasm", "module.bin", "misleading.js"])( + it.each(["module.wasm", "module.bin", "misleading.js", "misleading.hop"])( "identifies a WASM v1 header in %s without suggesting native provider installation", async (name) => { const directory = await createTestTempDirectory("rea-wasm-target-"); @@ -346,3 +346,68 @@ describe("raw-binary load rejection diagnostics", () => { }); }); }); + +describe("caller-selected and inferred target interpretation", () => { + it.each(["archive.hop", "archive.HOP"])( + "preserves inferred database routing for ZIP content named %s", + async (name) => { + const directory = await createTestTempDirectory("rea-zip-database-"); + const bytes = Buffer.alloc(22); + bytes.writeUInt32LE(0x06054b50, 0); + const zipPath = join(directory, "archive.zip"); + const databasePath = join(directory, name); + await writeFile(zipPath, bytes); + await writeFile(databasePath, bytes); + expect(await parseBinaryTarget(zipPath)).toMatchObject({ + ok: true, + value: { kind: "archive", format: "zip" }, + }); + expect(await parseBinaryTarget(databasePath)).toMatchObject({ + ok: true, + value: { kind: "database", format: "analysis-database" }, + }); + expect(await readFile(databasePath)).toEqual(bytes); + }, + ); + + it("preserves explicit DOS COM interpretation for bytes with a WASM header", async () => { + const directory = await createTestTempDirectory("rea-wasm-explicit-com-"); + const path = join(directory, "module.hop"); + await writeFile(path, Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0])); + expect( + await parseBinaryTarget(path, { formatHint: "dos-com" }), + ).toMatchObject({ + ok: true, + value: { kind: "executable", format: "dos-com", architecture: "x86" }, + }); + }); + it.each(["module.wasm", "misleading.hop"])( + "preserves explicitly selected database routing for %s", + async (name) => { + const directory = await createTestTempDirectory( + "rea-wasm-explicit-database-", + ); + const path = join(directory, name); + await writeFile(path, Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0])); + expect( + await parseBinaryTarget(path, { targetKind: "database" }), + ).toMatchObject({ + ok: true, + value: { kind: "database", format: "analysis-database" }, + }); + }, + ); + + it.each(["ordinary.hop", "ordinary.HOP"])( + "retains automatic database routing for non-WASM %s", + async (name) => { + const directory = await createTestTempDirectory("rea-ordinary-database-"); + const path = join(directory, name); + await writeFile(path, "existing database content"); + expect(await parseBinaryTarget(path)).toMatchObject({ + ok: true, + value: { kind: "database", format: "analysis-database" }, + }); + }, + ); +}); diff --git a/tests/boundary/mcp/analysisViewMcp.test.ts b/tests/boundary/mcp/analysisViewMcp.test.ts index b95aebe572..8fa3f0fb70 100644 --- a/tests/boundary/mcp/analysisViewMcp.test.ts +++ b/tests/boundary/mcp/analysisViewMcp.test.ts @@ -1,3 +1,4 @@ +import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; import { API_SURFACE_EXAMPLE } from "../../../src/contracts/analysisViewApiSurfaceExample.js"; import { createHash } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; @@ -212,16 +213,10 @@ it("advertises exact schemas and projects one layout section from retained Evide inspect: () => Promise.resolve(ok(layout)), }); const { client, session } = await connect(service); - const advertised = (await client.listTools()).tools.find( - (tool) => tool.name === "inspect_analysis_view", + const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas( + client, + "inspect_analysis_view", ); - if (advertised?.outputSchema === undefined) - throw new Error("inspect_analysis_view must publish both schemas"); - const ajv = new Ajv2020({ strict: false, validateFormats: false }); - const inputSchema: Record = advertised.inputSchema; - const outputSchema: Record = advertised.outputSchema; - expect(ajv.validateSchema(inputSchema)).toBe(true); - expect(ajv.validateSchema(outputSchema)).toBe(true); expect( ajv.validate(inputSchema, { source: { kind: "inline", evidence: analysisViewLayoutEvidence() }, diff --git a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts index bea7c89dd1..51bda12c7f 100644 --- a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts +++ b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts @@ -1,9 +1,11 @@ +import { + advertisedMcpToolSchemas, + assertRecordedMcpEvidence, +} from "../../fixtures/advertisedMcpTool.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import { Ajv2020 } from "ajv/dist/2020.js"; import { expect, it, onTestFinished } from "vitest"; import { BinaryLayoutService } from "../../../src/application/binaryDiagnostics/BinaryLayoutService.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; -import { parseEvidence } from "../../../src/domain/evidence.js"; import { ok } from "../../../src/domain/result.js"; import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; @@ -43,16 +45,11 @@ it("advertises exact valid schemas and records inline layout without a binary ta InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); await client.connect(clientTransport); - const advertised = (await client.listTools()).tools.find( - (tool) => tool.name === "inspect_binary_layout", + const schemas = await advertisedMcpToolSchemas( + client, + "inspect_binary_layout", ); - if (advertised?.outputSchema === undefined) - throw new Error("layout must publish both schemas"); - const ajv = new Ajv2020({ strict: false, validateFormats: false }); - const inputSchema: Record = advertised.inputSchema; - const outputSchema: Record = advertised.outputSchema; - expect(ajv.validateSchema(inputSchema)).toBe(true); - expect(ajv.validateSchema(outputSchema)).toBe(true); + const { ajv, inputSchema } = schemas; expect(ajv.validate(inputSchema, { path: value.artifact.path })).toBe(true); expect( ajv.validate(inputSchema, { @@ -64,19 +61,14 @@ it("advertises exact valid schemas and records inline layout without a binary ta name: "inspect_binary_layout", arguments: { path: value.artifact.path }, }); - expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); - const parsed = toolContract("inspect_binary_layout").outputSchema.parse( - response.structuredContent, + const evidence = assertRecordedMcpEvidence( + response, + "inspect_binary_layout", + schemas, + session, + value, + "observed", ); - const evidence = parseEvidence(parsed); - expect(evidence.confidence).toBe("observed"); - expect(evidence.subject?.digest.sha256).toBe(value.artifact.sha256); - expect(parsed.normalized_result).toEqual(value); - expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence); const viewed = await client.callTool({ name: "inspect_analysis_view", arguments: { diff --git a/tests/boundary/mcp/evmInterfaceMcp.test.ts b/tests/boundary/mcp/evmInterfaceMcp.test.ts index 60f493b2c6..2bfed5c813 100644 --- a/tests/boundary/mcp/evmInterfaceMcp.test.ts +++ b/tests/boundary/mcp/evmInterfaceMcp.test.ts @@ -1,3 +1,7 @@ +import { + advertisedMcpToolSchemas, + assertRecordedMcpEvidence, +} from "../../fixtures/advertisedMcpTool.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { createHash } from "node:crypto"; import { Ajv2020 } from "ajv/dist/2020.js"; @@ -66,16 +70,11 @@ it("advertises exact valid schemas and records inline EVM interface evidence wit InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); await client.connect(clientTransport); - const advertised = (await client.listTools()).tools.find( - (tool) => tool.name === "inspect_evm_interface", + const schemas = await advertisedMcpToolSchemas( + client, + "inspect_evm_interface", ); - if (advertised?.outputSchema === undefined) - throw new Error("EVM interface inspection must publish both schemas"); - const ajv = new Ajv2020({ strict: false, validateFormats: false }); - const inputSchema: Record = advertised.inputSchema; - const outputSchema: Record = advertised.outputSchema; - expect(ajv.validateSchema(inputSchema)).toBe(true); - expect(ajv.validateSchema(outputSchema)).toBe(true); + const { ajv, inputSchema } = schemas; expect( ajv.validate(inputSchema, { path: value.artifact.path, encoding: "hex" }), ).toBe(true); @@ -99,19 +98,14 @@ it("advertises exact valid schemas and records inline EVM interface evidence wit name: "inspect_evm_interface", arguments: { path: value.artifact.path, encoding: "hex" }, }); - expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); - const parsed = toolContract("inspect_evm_interface").outputSchema.parse( - response.structuredContent, + assertRecordedMcpEvidence( + response, + "inspect_evm_interface", + schemas, + session, + value, + "inferred", ); - const evidence = parseEvidence(parsed); - expect(evidence.confidence).toBe("inferred"); - expect(evidence.subject?.digest.sha256).toBe(value.artifact.sha256); - expect(parsed.normalized_result).toEqual(value); - expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence); expect( ( await client.callTool({ diff --git a/tests/boundary/mcp/wasmArtifactMcp.test.ts b/tests/boundary/mcp/wasmArtifactMcp.test.ts index 003a37cba9..04c5e59dad 100644 --- a/tests/boundary/mcp/wasmArtifactMcp.test.ts +++ b/tests/boundary/mcp/wasmArtifactMcp.test.ts @@ -1,5 +1,9 @@ -import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { Ajv2020 } from "ajv/dist/2020.js"; +import { + advertisedMcpToolSchemas, + assertRecordedMcpEvidence, +} from "../../fixtures/advertisedMcpTool.js"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { expect, it, onTestFinished, vi } from "vitest"; import { readFile, realpath, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -20,20 +24,6 @@ import { wasmArtifactExecution, } from "../../fixtures/wasm/artifact.js"; -const wasmToolSchemas = async (client: Client) => { - const advertised = (await client.listTools()).tools.find( - (tool) => tool.name === "inspect_wasm_artifact", - ); - if (advertised?.outputSchema === undefined) - throw new Error("WASM recovery must name an advertised static tool"); - const ajv = new Ajv2020({ strict: false, validateFormats: false }); - const inputSchema: Record = advertised.inputSchema; - const outputSchema: Record = advertised.outputSchema; - expect(ajv.validateSchema(inputSchema)).toBe(true); - expect(ajv.validateSchema(outputSchema)).toBe(true); - return { name: advertised.name, ajv, inputSchema, outputSchema }; -}; - const assertFirstSummaryDelivery = async ( harness: Awaited>, value: ReturnType, @@ -43,7 +33,7 @@ const assertFirstSummaryDelivery = async ( ajv: wasmAjv, inputSchema, outputSchema, - } = await wasmToolSchemas(harness.client); + } = await advertisedMcpToolSchemas(harness.client, "inspect_wasm_artifact"); const summaryInput = { path: value.artifact.path, detail: "summary" }; expect(wasmAjv.validate(inputSchema, summaryInput)).toBe(true); expect( @@ -91,73 +81,86 @@ const assertFirstSummaryDelivery = async ( return { summary, retained }; }; -it("directs rejected native WASM opening to the advertised static tool while preserving missing-WABT diagnostics", async () => { - const directory = await createTestTempDirectory("rea-wasm-recovery-"); - const path = join( - await realpath(directory), - process.platform === "win32" ? "module space.bin" : 'module "quoted".bin', - ); - await writeFile(path, Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0])); - const createClient = vi.fn(() => { - throw new Error("native provider must not start for WASM"); - }); - const session = createTestBinarySession(createClient); - const harness = await createApplicationMcpHarness({ - session, - serverOptions: { environment: {} }, - }); - onTestFinished(harness.close); - const { client } = harness; - const { name, ajv, inputSchema } = await wasmToolSchemas(client); - const input = { path }; - expect( - toolContract("inspect_wasm_artifact").inputSchema.safeParse(input).success, - ).toBe(true); - const opening = await client.callTool({ - name: "open_binary", - arguments: input, - }); - expect(opening.isError).toBe(true); - const failure = parseMcpToolError(opening); - expect(failure).toMatchObject({ - error: { - code: "target_unavailable", - category: "unsupported_target", - details: { constraint: "wasm_requires_static_inspection" }, - }, - }); - expect(failure.error.remediation.action).toContain(JSON.stringify({ path })); - expect(ajv.validate(inputSchema, input)).toBe(true); - const inspection = await client.callTool({ - name, - arguments: input, - }); - expect(inspection.isError).toBe(true); - expect(parseMcpToolError(inspection)).toMatchObject({ - error: { - code: "capability_unavailable", - details: { - operation: "inspect_wasm_artifact", - reason: expect.stringContaining("REA_WABT_BIN_DIRECTORY"), +it.each(["bin", "hop"])( + "directs rejected native WASM opening (%s suffix) to the advertised static tool while preserving missing-WABT diagnostics", + async (extension) => { + const directory = await createTestTempDirectory("rea-wasm-recovery-"); + const path = join( + await realpath(directory), + process.platform === "win32" + ? `module space.${extension}` + : `module "quoted".${extension}`, + ); + await writeFile(path, Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0])); + const createClient = vi.fn(() => { + throw new Error("native provider must not start for WASM"); + }); + const session = createTestBinarySession(createClient); + const harness = await createApplicationMcpHarness({ + session, + serverOptions: { environment: {} }, + }); + onTestFinished(harness.close); + const { client } = harness; + const { name, ajv, inputSchema } = await advertisedMcpToolSchemas( + client, + "inspect_wasm_artifact", + ); + const input = { path }; + const opening = await client.callTool({ + name: "open_binary", + arguments: input, + }); + expect(opening.isError).toBe(true); + const failure = parseMcpToolError(opening); + expect(failure).toMatchObject({ + error: { + code: "target_unavailable", + category: "unsupported_target", + details: { constraint: "wasm_requires_static_inspection" }, }, - }, - }); - const summary = await client.callTool({ - name, - arguments: { ...input, detail: "summary" }, - }); - expect(parseMcpToolError(summary)).toMatchObject({ - error: { - code: "capability_unavailable", - details: { reason: expect.stringContaining("REA_WABT_BIN_DIRECTORY") }, - }, - }); - await client.ping(); - expect(createClient).not.toHaveBeenCalled(); - expect(await readFile(path)).toEqual( - Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]), - ); -}); + }); + const [, recoveryName, serializedInput] = + failure.error.remediation.action.match( + /call (\w+) with (\{.*\}) or run/, + ) ?? []; + expect(recoveryName).toBe(name); + const recoveryInput = z + .object({ path: z.literal(path) }) + .strict() + .parse(JSON.parse(serializedInput ?? "null")); + expect(ajv.validate(inputSchema, recoveryInput)).toBe(true); + const inspection = await client.callTool({ + name: recoveryName ?? "", + arguments: recoveryInput, + }); + expect(inspection.isError).toBe(true); + expect(parseMcpToolError(inspection)).toMatchObject({ + error: { + code: "capability_unavailable", + details: { + operation: "inspect_wasm_artifact", + reason: expect.stringContaining("REA_WABT_BIN_DIRECTORY"), + }, + }, + }); + const summary = await client.callTool({ + name, + arguments: { ...input, detail: "summary" }, + }); + expect(parseMcpToolError(summary)).toMatchObject({ + error: { + code: "capability_unavailable", + details: { reason: expect.stringContaining("REA_WABT_BIN_DIRECTORY") }, + }, + }); + await client.ping(); + expect(createClient).not.toHaveBeenCalled(); + expect(await readFile(path)).toEqual( + Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]), + ); + }, +); it("advertises exact valid schemas and records inline WASM interface evidence without a binary target", async () => { const value = wasmArtifactFixture(); @@ -175,7 +178,11 @@ it("advertises exact valid schemas and records inline WASM interface evidence wi }); onTestFinished(harness.close); const { client } = harness; - const { ajv, inputSchema, outputSchema } = await wasmToolSchemas(client); + const schemas = await advertisedMcpToolSchemas( + client, + "inspect_wasm_artifact", + ); + const { ajv, inputSchema } = schemas; expect(ajv.validate(inputSchema, { path: value.artifact.path })).toBe(true); expect( ajv.validate(inputSchema, { @@ -188,19 +195,14 @@ it("advertises exact valid schemas and records inline WASM interface evidence wi name: "inspect_wasm_artifact", arguments: { path: value.artifact.path }, }); - expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); - const parsed = toolContract("inspect_wasm_artifact").outputSchema.parse( - response.structuredContent, + assertRecordedMcpEvidence( + response, + "inspect_wasm_artifact", + schemas, + session, + value, + "observed", ); - const evidence = parseEvidence(parsed); - expect(evidence.confidence).toBe("observed"); - expect(evidence.subject?.digest.sha256).toBe(value.artifact.sha256); - expect(parsed.normalized_result).toEqual(value); - expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence); expect( ( await client.callTool({ diff --git a/tests/fixtures/advertisedMcpTool.ts b/tests/fixtures/advertisedMcpTool.ts new file mode 100644 index 0000000000..854d272ba4 --- /dev/null +++ b/tests/fixtures/advertisedMcpTool.ts @@ -0,0 +1,52 @@ +import type { Client } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect } from "vitest"; +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import type { BinarySession } from "../../src/application/binary/BinarySession.js"; + +/** Verify the actual advertised schemas, retaining a private Ajv registry. */ +export const advertisedMcpToolSchemas = async ( + client: Client, + name: string, +) => { + const advertised = (await client.listTools()).tools.find( + (tool) => tool.name === name, + ); + if (advertised?.outputSchema === undefined) + throw new Error(`${name} must publish both schemas`); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const inputSchema: Record = advertised.inputSchema; + const outputSchema: Record = advertised.outputSchema; + expect(ajv.validateSchema(inputSchema)).toBe(true); + expect(ajv.validateSchema(outputSchema)).toBe(true); + return { name: advertised.name, ajv, inputSchema, outputSchema }; +}; + +/** Keep public output, provenance and session retention checks together. */ +export const assertRecordedMcpEvidence = ( + response: Awaited>, + name: + | "inspect_wasm_artifact" + | "inspect_binary_layout" + | "inspect_evm_interface", + schemas: Awaited>, + session: BinarySession, + expected: { readonly artifact: { readonly sha256: string } }, + confidence: ReturnType["confidence"], +) => { + expect(response.isError).not.toBe(true); + expect( + schemas.ajv.validate(schemas.outputSchema, response.structuredContent), + JSON.stringify(schemas.ajv.errors), + ).toBe(true); + const parsed = toolContract(name).outputSchema.parse( + response.structuredContent, + ); + const evidence = parseEvidence(parsed); + expect(evidence.confidence).toBe(confidence); + expect(evidence.subject?.digest.sha256).toBe(expected.artifact.sha256); + expect(parsed.normalized_result).toEqual(expected); + expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence); + return evidence; +};