From ffaf2c12f1793c7c451e66dc09c3074544a4c086 Mon Sep 17 00:00:00 2001 From: Thang Luu Date: Sun, 11 Oct 2026 20:49:00 +0900 Subject: [PATCH] feat(wasm): inspect local artifacts with WABT (#1533) * feat(wasm): inspect local artifacts with WABT * fix(wasm): preserve custom names and failed cleanup ownership --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../skills/reverse-engineer-anything/SKILL.md | 7 +- README.md | 1 + docs/testing.md | 22 + docs/wasm-artifacts.md | 71 +++ package.json | 3 + scripts/lib/catalog-builders.mjs | 5 + scripts/lib/catalog-core.mjs | 2 + scripts/lib/module-boundaries.mjs | 1 + scripts/verify-packaged-wasm-artifact.mjs | 57 +++ scripts/verify-wasm-artifact.mjs | 313 +++++++++++++ src/application/CapabilityInventory.ts | 9 + src/application/wasm/WasmArtifactPort.ts | 17 + .../wasm/WasmArtifactService.test.ts | 79 ++++ src/application/wasm/WasmArtifactService.ts | 100 ++++ src/artifacts/MachOSliceArtifactReader.ts | 2 +- src/cli.ts | 2 + src/cli/wasmCommands.ts | 60 +++ src/cliCommandNames.ts | 1 + src/composition/wasm.ts | 7 + src/contracts/toolContracts.ts | 2 + src/contracts/toolEffects.ts | 5 + src/contracts/wasm/wasmToolContracts.ts | 29 ++ src/domain/canonicalJsonDigestSteps.test.ts | 2 +- src/domain/wasm/wasmArtifact.ts | 126 +++++ src/generatedPackageMetadata.ts | 2 +- src/native/NativeMacOSProvider.ts | 2 +- src/server/createServer.ts | 26 +- src/server/registerWasmTools.ts | 32 ++ src/server/sessionAvailabilityPolicy.ts | 3 + src/wasm/WabtArtifactProvider.test.ts | 194 ++++++++ src/wasm/WabtArtifactProvider.ts | 439 ++++++++++++++++++ src/wasm/WabtFailures.ts | 111 +++++ src/wasm/WabtOutput.test.ts | 92 ++++ src/wasm/WabtOutput.ts | 66 +++ src/wasm/WabtRelease.ts | 12 + src/wasm/WabtWat.ts | 124 +++++ src/wasm/WasmGlueAdapter.ts | 71 +++ tests/boundary/mcp/wasmArtifactMcp.test.ts | 84 ++++ tests/fixtures/wasm/README.md | 13 + tests/fixtures/wasm/artifact.ts | 52 +++ tests/fixtures/wasm/custom-names.wat | 4 + tests/fixtures/wasm/decoded.wat | 9 + tests/fixtures/wasm/module.wat | 1 + tests/fixtures/wasm/objdump.txt | 30 ++ third_party/wabt/README.md | 24 + vitest.config.ts | 2 +- 46 files changed, 2309 insertions(+), 7 deletions(-) create mode 100644 docs/wasm-artifacts.md create mode 100644 scripts/verify-packaged-wasm-artifact.mjs create mode 100644 scripts/verify-wasm-artifact.mjs create mode 100644 src/application/wasm/WasmArtifactPort.ts create mode 100644 src/application/wasm/WasmArtifactService.test.ts create mode 100644 src/application/wasm/WasmArtifactService.ts create mode 100644 src/cli/wasmCommands.ts create mode 100644 src/composition/wasm.ts create mode 100644 src/contracts/wasm/wasmToolContracts.ts create mode 100644 src/domain/wasm/wasmArtifact.ts create mode 100644 src/server/registerWasmTools.ts create mode 100644 src/wasm/WabtArtifactProvider.test.ts create mode 100644 src/wasm/WabtArtifactProvider.ts create mode 100644 src/wasm/WabtFailures.ts create mode 100644 src/wasm/WabtOutput.test.ts create mode 100644 src/wasm/WabtOutput.ts create mode 100644 src/wasm/WabtRelease.ts create mode 100644 src/wasm/WabtWat.ts create mode 100644 src/wasm/WasmGlueAdapter.ts create mode 100644 tests/boundary/mcp/wasmArtifactMcp.test.ts create mode 100644 tests/fixtures/wasm/README.md create mode 100644 tests/fixtures/wasm/artifact.ts create mode 100644 tests/fixtures/wasm/custom-names.wat create mode 100644 tests/fixtures/wasm/decoded.wat create mode 100644 tests/fixtures/wasm/module.wat create mode 100644 tests/fixtures/wasm/objdump.txt create mode 100644 third_party/wabt/README.md diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index b46416786..5fe6b6f0a 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -2,7 +2,7 @@ name: reverse-engineer-anything description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis. metadata: - version: "37" + version: "38" --- # REA @@ -67,6 +67,11 @@ explicit path or endpoint and do not need it. [historical captures](https://github.com/morluto/rea/blob/main/docs/web-network-captures.md) for the exact upstream profile and credential exclusions. - User-owned Electron runtime already open: `list_electron_targets`. +- Explicit local WebAssembly artifact: `inspect_wasm_artifact`. Supply WABT 1.0.42 + through `REA_WABT_BIN_DIRECTORY`; retain exact byte/tool identity and decoded WAT. + Select JavaScript glue and candidate paths explicitly and preserve URL/path + ambiguity. No implicit fetch or target execution; WAT is not original source. + See the [WASM guide](https://github.com/morluto/rea/blob/main/docs/wasm-artifacts.md). - Explicit local EVM bytecode carrier: `inspect_evm_interface` with caller-selected `raw` or `hex` encoding. Preserve carrier/decoded digests and treat selectors, argument strings and mutability as inferred candidates; this performs no chain diff --git a/README.md b/README.md index 7fe013f92..ac85e7548 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ Additional tools and host support depend on the target: | ---------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | | Native binaries | Pseudocode, assembly, strings, symbols, calls and references | Hopper, Ghidra or IDA; [native analysis](https://rea.tools/guides/native/) | | Offline ELF layout | Sections, segments, original symbols/relocations and static mitigation candidates | Caller-supplied pwntools on Linux x64; [binary diagnostics](docs/binary-diagnostics.md) | +| WASM artifact | Validation, sections, exact import/export WAT forms and decoded WAT | Caller-supplied WABT 1.0.42; `inspect_wasm_artifact` | | EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier on Linux x64; [offline EVM guide](docs/evm-bytecode.md) | | Recorded Linux crashes | Raw notes, every recorded thread's registers/signals and optional mapping candidates | Caller-supplied pwntools on Linux x64; optional GDB/pwndbg; [recorded crashes](docs/recorded-crashes.md) | | JavaScript / Electron | Modules, imports, source maps, routes, IPC and native add-on relationships | Node.js and npm; [application analysis](https://rea.tools/guides/javascript/) | diff --git a/docs/testing.md b/docs/testing.md index cfa74a190..7574489b8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1331,3 +1331,25 @@ npm run evidence:check The records preserve unsupported and unverified coverage as explicit unknowns. Run the matching real-tool prerequisites described in this guide. + +### Offline WASM artifacts + +`npm run verify:wasm:artifact -- --require-tools` uses an absolute +`REA_WABT_BIN_DIRECTORY` containing WABT 1.0.42, including `wat2wasm` for fixture +generation. It installs nothing. Without configuration, the default invocation +reports a named skip; `--require-tools` makes missing configuration fail. + +The verifier creates and validates real modules with custom sections, multiple +import/export kinds, escaped names and empty modules. It compares exact WAT, +section output and selected/tool digests through the compiled CLI and real MCP +SDK, validates advertised schemas, checks identical CLI/MCP Evidence IDs and +session bundle readback, retains invalid/truncated module diagnostics, preserves +all inputs and checks workspace cleanup. Pass an installed package's +`scripts/rea.mjs` as the first argument to run the same lane against the package. +`npm run verify:wasm:package` packs and installs REA into an isolated temporary +prefix with lifecycle scripts disabled and runs the same required real-tool lane. +It requires WABT configuration and makes no host tool installation. + +Source-owned provider tests separately exercise cancellation, deadline propagation, +malformed producer output and cleanup uncertainty; shared owned-command tests +exercise actual subprocess timeout and cancellation. diff --git a/docs/wasm-artifacts.md b/docs/wasm-artifacts.md new file mode 100644 index 000000000..647396d53 --- /dev/null +++ b/docs/wasm-artifacts.md @@ -0,0 +1,71 @@ +# Offline WebAssembly artifacts + +`inspect_wasm_artifact` / `inspect-wasm-artifact` inspects explicitly selected +local WASM bytes using caller-supplied WABT 1.0.42. No active binary target is +required. REA does not install tools or fetch artifacts and does not execute WASM. + +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 +unavailable diagnostics. Capability discovery reports configured availability; +execution validates tool files and version banners. Host process-ownership and +private-workspace controls must also be available. The real-tool profile has +been exercised on macOS arm64; other hosts require their own verification. + +```bash +REA_WABT_BIN_DIRECTORY=/absolute/wabt/bin rea inspect-wasm-artifact \ + /artifacts/module.wasm --glue_paths /artifacts/glue.js \ + --candidate_paths /artifacts/other/module.wasm --format json +``` + +Equivalent MCP input: + +```json +{ + "name": "inspect_wasm_artifact", + "arguments": { + "path": "/artifacts/module.wasm", + "glue_paths": ["/artifacts/glue.js"], + "candidate_paths": ["/artifacts/other/module.wasm"] + } +} +``` + +Each selected regular file is read through the existing stable artifact reader; +symlinks, changing files and files over 8 MiB fail closed. WASM bytes are copied +into a private workspace as read-only `module.wasm`; its digest is rechecked +before and after each tool. WABT validates with `--enable-all`, +reports sections through `wasm-objdump -h -x`, and decodes through +`wasm2wat --enable-all`. The result retains selected byte SHA-256 and size, +section payload ranges, full header/detail output, exact import/export WAT +forms, decoded WAT and its UTF-8 SHA-256. Producer format drift and invalid or +truncated modules are failures, with validation diagnostics and partial Evidence +retaining selected byte identity and tool provenance. + +The tool profile preserves upstream release/source identity, resolved executable +paths, executable digests, exact banners and arguments. Executable digests are +checked again after each command. Caller-supplied tools are trusted programs; +version and digest recording are not publisher authentication or an OS sandbox. +Each subprocess has a 30-second deadline and 32 MiB combined output budget; +truncation fails. All commands use existing owned-process cancellation and +cleanup. Workspace cleanup failures remain explicit errors with resource paths. + +WAT is a decoded representation, not original source or proof of runtime +behavior. Custom annotations can preserve payloads, but WAT does not establish original +binary layout or round-trip byte identity. Original byte identity and objdump +output retain that distinction. Import/export forms retain exact escaped WAT names. Unescaped objdump display +names are not parsed as unambiguous identifiers. + +Optional JavaScript glue uses the existing Babel parser and AST traversal. It +retains `.wasm` string literals and literal templates with source coordinates, +selected glue byte identity and parser status. Relative filesystem matches become +local path candidates. URLs, query/fragment variants and unmatched paths retain +basename candidates, including multiple matches, or remain unresolved. These are +literal associations, not proven data flow, runtime loading or URL-to-file +identity. Computed strings, aliases and dynamic imports require further evidence. +Candidates are caller-selected files; their identity does not imply validation. + +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](../third_party/wabt/README.md) and +[testing](testing.md#offline-wasm-artifacts) for real-tool verification. diff --git a/package.json b/package.json index 80d42cf6b..06cda8c08 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "bridge/pwntools/layout.py", "bridge/pwntools/layout_impl.py", "third_party/pwntools/README.md", + "third_party/wabt/README.md", "third_party/evmole/README.md", "third_party/evmole/LICENSE", "bridge/pwntools/decoder_runtime.py", @@ -241,6 +242,8 @@ "verify:browser:runtime": "npm run build:cached && node scripts/verify-browser-runtime.mjs", "verify:web:network-captures": "npm run build:cached && node scripts/verify-web-network-captures.mjs", "verify:binary:layout": "npm run build:cached && node scripts/verify-binary-layout.mjs", + "verify:wasm:package": "npm run build:cached && node scripts/verify-packaged-wasm-artifact.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:recorded:crash": "npm run build:cached && node scripts/verify-recorded-crash.mjs", "mcp-catalog:check:built": "node scripts/generate-mcp-tool-catalog.mjs --check", diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index 362c2acaa..83b9a1792 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -27,6 +27,7 @@ export const toolFamilyCatalog = (sources) => { contracts: [ ...sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS, ...sources.evmContracts.EVM_TOOL_CONTRACTS, + ...sources.wasmContracts.WASM_TOOL_CONTRACTS, ], }, { @@ -179,6 +180,10 @@ export const providerCatalog = (sources) => { ({ name }) => name === "inspect_pe_resources", ), }, + { + identity: sources.wasmProvider.WABT_PROVIDER_IDENTITY, + contracts: sources.wasmContracts.WASM_TOOL_CONTRACTS, + }, { identity: sources.evmProvider.EVMOLE_PROVIDER_IDENTITY, contracts: sources.evmContracts.EVM_TOOL_CONTRACTS, diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index 06b237d51..efd55ef8a 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -45,6 +45,8 @@ const SOURCE_PATHS = { "dist/contracts/native/binaryDiagnosticsToolContracts.js", pwntoolsProvider: "dist/native/pwntools/PwntoolsRelease.js", nativeContracts: "dist/contracts/native/nativeToolContracts.js", + wasmContracts: "dist/contracts/wasm/wasmToolContracts.js", + wasmProvider: "dist/wasm/WabtRelease.js", evmContracts: "dist/contracts/evm/evmToolContracts.js", evmProvider: "dist/evm/EvmoleRelease.js", artifactContracts: "dist/contracts/artifactToolContracts.js", diff --git a/scripts/lib/module-boundaries.mjs b/scripts/lib/module-boundaries.mjs index 2948cae30..94f714dff 100644 --- a/scripts/lib/module-boundaries.mjs +++ b/scripts/lib/module-boundaries.mjs @@ -6,6 +6,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const PURE_LAYERS = new Set(["domain", "contracts"]); const MIGRATED_PROVIDER_ROOTS = new Set([ "evm", + "wasm", "android", "firmware", "ghidra", diff --git a/scripts/verify-packaged-wasm-artifact.mjs b/scripts/verify-packaged-wasm-artifact.mjs new file mode 100644 index 000000000..d425ef924 --- /dev/null +++ b/scripts/verify-packaged-wasm-artifact.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +import { join } from "node:path"; +import { mkdir } from "node:fs/promises"; +import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js"; +import { exec } from "./lib/verify-package-core.mjs"; +import { verifyPackagePack } from "./verify-package-pack.mjs"; + +const root = await PrivateRuntimeRoot.create({ + prefix: "rea-wabt-package-verifier-", +}); +try { + const { tarball } = await verifyPackagePack({ + root: process.cwd(), + workspace: root.path, + }); + const prefix = join(root.path, "prefix"); + const home = join(root.path, "home"); + await mkdir(home); + const environment = { + ...process.env, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, ".config"), + XDG_CACHE_HOME: join(home, ".cache"), + }; + await exec( + "npm", + [ + "install", + "--global", + "--ignore-scripts", + "--omit=dev", + "--install-strategy=nested", + "--prefix", + prefix, + tarball, + ], + { env: environment, timeout: 120_000, maxBuffer: 4 * 1024 * 1024 }, + ); + const entrypoint = join( + prefix, + ...(process.platform === "win32" + ? ["node_modules"] + : ["lib", "node_modules"]), + "rea-agents", + "scripts", + "rea.mjs", + ); + const verified = await exec( + process.execPath, + ["scripts/verify-wasm-artifact.mjs", entrypoint, "--require-tools"], + { env: environment, timeout: 180_000, maxBuffer: 4 * 1024 * 1024 }, + ); + process.stdout.write(verified.stdout); +} finally { + await root.close(); +} diff --git a/scripts/verify-wasm-artifact.mjs b/scripts/verify-wasm-artifact.mjs new file mode 100644 index 000000000..ce139c5e2 --- /dev/null +++ b/scripts/verify-wasm-artifact.mjs @@ -0,0 +1,313 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile, readdir } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js"; +import { parseEvidence } from "../dist/domain/evidence.js"; +import { + mcpTextValue, + requireMcpToolError, +} from "./lib/mcp-verifier-results.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; + +const directory = process.env.REA_WABT_BIN_DIRECTORY; +if (!isAbsolute(directory ?? "")) { + if (process.argv.includes("--require-tools")) + throw new Error( + "Supply absolute REA_WABT_BIN_DIRECTORY containing WABT 1.0.42 tools.", + ); + console.log( + JSON.stringify({ + status: "skipped", + reason: + "WABT real-tool lane requires caller-supplied REA_WABT_BIN_DIRECTORY; no tools installed.", + }), + ); + process.exit(0); +} +const entrypoint = + process.argv.slice(2).find((arg) => !arg.startsWith("--")) ?? + fileURLToPath(new URL("./rea.mjs", import.meta.url)); +const execute = promisify(execFile); +const environment = Object.fromEntries( + Object.entries(process.env).filter(([, value]) => typeof value === "string"), +); +const toolPath = (tool) => + join(directory, `${tool}${process.platform === "win32" ? ".exe" : ""}`); +const tool = (name, args, cwd) => + execute(toolPath(name), args, { + cwd, + env: environment, + timeout: 30_000, + maxBuffer: 32 * 1024 * 1024, + }); +const sha256 = (value) => createHash("sha256").update(value).digest("hex"); +const run = createVerifierRun(); +const root = await PrivateRuntimeRoot.create({ prefix: "rea-wabt-verifier-" }); +const client = new Client({ name: "wabt-artifact-verifier", version: "1" }); +const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: { ...environment, TMPDIR: root.path, TMP: root.path, TEMP: root.path }, + stderr: "pipe", +}); +let cases = 0; +const failures = []; +try { + for (const name of ["wat2wasm", "wasm-validate", "wasm-objdump", "wasm2wat"]) + assert.equal( + (await tool(name, ["--version"], root.path)).stdout.trim(), + "1.0.42", + ); + await client.connect(transport); + const advertised = (await client.listTools()).tools.find( + ({ name }) => name === "inspect_wasm_artifact", + ); + assert.ok(advertised?.outputSchema, "WASM tool must advertise output schema"); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + assert.ok(ajv.validateSchema(advertised.inputSchema)); + assert.ok(ajv.validateSchema(advertised.outputSchema)); + const inspectCli = async (input) => { + try { + return JSON.parse( + ( + await execute( + process.execPath, + [ + entrypoint, + "inspect-wasm-artifact", + input.path, + ...input.glue_paths.flatMap((path) => ["--glue_paths", path]), + ...input.candidate_paths.flatMap((path) => [ + "--candidate_paths", + path, + ]), + "--format", + "json", + ], + { + env: { + ...environment, + TMPDIR: root.path, + TMP: root.path, + TEMP: root.path, + }, + timeout: 120_000, + maxBuffer: 64 * 1024 * 1024, + }, + ) + ).stdout, + ); + } catch (cause) { + if (typeof cause.code === "number" && typeof cause.stdout === "string") + return JSON.parse(cause.stdout); + throw cause; + } + }; + const sources = [ + await readFile( + new URL("../tests/fixtures/wasm/module.wat", import.meta.url), + "utf8", + ), + "(module)\n", + '(module (import "env.with.dot" "field.with.dot" (global i32)) (import "env" "table" (table 1 funcref)) (memory (export "memory") 1) (export "g" (global 0)) (export "t" (table 0)) (func (export "name\\0aline") (result i32) i32.const 42))\n', + await readFile( + new URL("../tests/fixtures/wasm/custom-names.wat", import.meta.url), + "utf8", + ), + ]; + for (const [index, source] of sources.entries()) { + const fixtureRoot = join(root.path, `fixture-${index}`); + await mkdir(fixtureRoot); + const path = join(fixtureRoot, "module.wasm"); + await writeFile(join(fixtureRoot, "source.wat"), source); + await tool( + "wat2wasm", + ["--enable-all", "source.wat", "-o", "module.wasm"], + fixtureRoot, + ); + await tool("wasm-validate", ["--enable-all", "module.wasm"], fixtureRoot); + const original = await readFile(path); + // Independent modes avoid treating a custom name's text as a framing marker. + const headers = ( + await tool("wasm-objdump", ["-h", "module.wasm"], fixtureRoot) + ).stdout; + const details = ( + await tool("wasm-objdump", ["-x", "module.wasm"], fixtureRoot) + ).stdout; + const wat = ( + await tool("wasm2wat", ["--enable-all", "module.wasm"], fixtureRoot) + ).stdout; + await writeFile(join(fixtureRoot, "decoded.wat"), wat); + await tool( + "wat2wasm", + ["--enable-all", "decoded.wat", "-o", "roundtrip.wasm"], + fixtureRoot, + ); + await tool( + "wasm-validate", + ["--enable-all", "roundtrip.wasm"], + fixtureRoot, + ); + const candidateRoot = join(fixtureRoot, "other"); + await mkdir(candidateRoot); + const candidate = join(candidateRoot, "module.wasm"); + await writeFile(join(candidateRoot, "source.wat"), "(module (memory 2))"); + await tool("wat2wasm", ["source.wat", "-o", "module.wasm"], candidateRoot); + const glue = join(fixtureRoot, "glue.js"); + await writeFile( + glue, + 'fetch("./module.wasm"); new URL("https://example.invalid/module.wasm?version=1", import.meta.url);', + ); + const input = { path, glue_paths: [glue], candidate_paths: [candidate] }; + assert.ok(ajv.validate(advertised.inputSchema, input)); + const cliValue = await inspectCli(input); + assert.equal(cliValue.category, undefined, JSON.stringify(cliValue)); + const cli = parseEvidence(cliValue); + const reply = await client.callTool({ + name: "inspect_wasm_artifact", + arguments: input, + }); + assert.notEqual(reply.isError, true, mcpTextValue(reply)); + assert.ok( + ajv.validate(advertised.outputSchema, reply.structuredContent), + JSON.stringify(ajv.errors), + ); + const mcp = parseEvidence(reply.structuredContent); + assert.equal(cli.evidence_id, mcp.evidence_id); + assert.deepEqual(cli, mcp); + const report = mcp.normalized_result; + assert.equal(report.artifact.sha256, sha256(original)); + assert.equal(report.artifact.bytes, original.length); + assert.equal(report.wat.text, wat); + assert.equal(report.wat.sha256, sha256(wat)); + assert.equal(report.headers, `${headers}\n`); + assert.equal(report.details, details.slice(1)); + assert.equal(report.glue[0].artifact.sha256, sha256(await readFile(glue))); + assert.deepEqual( + report.glue[0].references.map(({ candidate_paths }) => candidate_paths), + [[path], [path, candidate]], + ); + assert.notEqual(report.candidates[0].sha256, report.candidates[1].sha256); + for (const command of report.tool_profile.commands) + assert.equal(command.sha256, sha256(await readFile(command.path))); + if (index === 0) { + assert.ok( + report.sections.some( + ({ kind, description }) => + kind === "Custom" && description === '"test"', + ), + ); + assert.ok(report.wat.text.includes('(@custom "test" "payload")')); + assert.equal( + sha256(await readFile(join(fixtureRoot, "roundtrip.wasm"))), + sha256(original), + ); + } + if (index === 3) + assert.deepEqual( + report.sections.map(({ description }) => description), + [ + '"test\nline"', + '"fake"\nSection Details:\n Type start=0x0000000a end=0x0000000b (size=0x00000001) count: 0\n"end"', + '"unicode-é\\\t"', + ], + ); + const bundleReply = await client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }); + assert.notEqual(bundleReply.isError, true, mcpTextValue(bundleReply)); + const bundle = JSON.parse(mcpTextValue(bundleReply)).result; + const retained = bundle.records.find( + ({ evidence_id }) => evidence_id === mcp.evidence_id, + ); + assert.deepEqual(parseEvidence(retained), mcp); + assert.deepEqual(await readFile(path), original); + cases += 2; + for (const [label, invalidBytes] of [ + ["truncated", original.subarray(0, 7)], + ["invalid", Buffer.from("not a wasm module")], + ]) { + const invalidPath = join(fixtureRoot, `${label}.wasm`); + await writeFile(invalidPath, invalidBytes); + const invalidInput = { + path: invalidPath, + glue_paths: [], + candidate_paths: [], + }; + const cliError = await inspectCli(invalidInput); + assert.equal( + cliError.category, + "invalid_input", + JSON.stringify(cliError), + ); + const error = requireMcpToolError( + await client.callTool({ + name: "inspect_wasm_artifact", + arguments: invalidInput, + }), + ); + assert.equal(error.category, "invalid_input"); + const failed = parseEvidence(error.details.partial_observation); + assert.equal(failed.subject.digest.sha256, sha256(invalidBytes)); + assert.equal( + failed.normalized_result.validation, + "rejected-by-selected-profile", + ); + assert.equal( + cliError.details.partial_observation.evidence_id, + failed.evidence_id, + ); + assert.ok( + error.details?.captured_output?.stderr?.length > 0, + JSON.stringify(error), + ); + assert.deepEqual(await readFile(invalidPath), invalidBytes); + cases += 2; + } + } +} catch (cause) { + failures.push(cause); +} +try { + await client.close(); +} catch (cause) { + failures.push(cause); +} +try { + await transport.close(); +} catch (cause) { + failures.push(cause); +} +try { + assert.ok( + !(await readdir(root.path)).some((name) => name.startsWith("rea-wabt-")), + "Provider workspace leaked", + ); + await root.close(); +} catch (cause) { + failures.push(cause); +} +if (failures.length > 0) + throw new AggregateError(failures, "WABT real-tool verification failed."); +console.log( + JSON.stringify( + { + status: "passed", + cases, + tool_profile: "wabt@1.0.42", + entrypoint, + verifier: await completeVerifierRun(run), + }, + null, + 2, + ), +); diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 7d3231d90..104aa7c76 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -32,6 +32,7 @@ type ToolAvailabilityReason = "available" | ToolUnavailabilityReason; type ProviderDescriptor = ProviderCapability; export type AvailabilityPolicy = { + readonly wasmArtifactEnabled?: boolean; readonly evmInterfaceEnabled?: boolean; readonly processCaptureEnabled: boolean; readonly optionalProviderLoadFailures?: OptionalProviderLoadFailures; @@ -314,6 +315,14 @@ const workflowAvailabilityFor = ({ remediation: "On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.", }; + if (name === "inspect_wasm_artifact") + return policy.wasmArtifactEnabled === true + ? { reason: "available", remediation: null } + : { + reason: "provider_missing", + remediation: + "Supply WABT 1.0.42 via absolute REA_WABT_BIN_DIRECTORY. No active binary target is required; tools are checked at execution.", + }; if (name === "inspect_pe_resources") return { reason: "available", remediation: null }; if (name === "inspect_evm_interface") diff --git a/src/application/wasm/WasmArtifactPort.ts b/src/application/wasm/WasmArtifactPort.ts new file mode 100644 index 000000000..6cf6de352 --- /dev/null +++ b/src/application/wasm/WasmArtifactPort.ts @@ -0,0 +1,17 @@ +import type { + AnalysisExecution, + ExecutionOptions, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { InspectWasmArtifactInput } from "../../domain/wasm/wasmArtifact.js"; +import type { Result } from "../../domain/result.js"; + +/** Replaceable offline WASM inspection engine, independent of target execution and network clients. */ +export interface WasmArtifactPort { + /** Retry removal of retained workspaces; failures preserve ownership for another attempt. */ + close(): Promise; + inspect( + input: InspectWasmArtifactInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/wasm/WasmArtifactService.test.ts b/src/application/wasm/WasmArtifactService.test.ts new file mode 100644 index 000000000..570fc0a2b --- /dev/null +++ b/src/application/wasm/WasmArtifactService.test.ts @@ -0,0 +1,79 @@ +import { expect, it } from "vitest"; +import { WasmArtifactService } from "./WasmArtifactService.js"; +import { + wasmArtifactFixture, + wasmArtifactExecution, +} from "../../../tests/fixtures/wasm/artifact.js"; +import { ok } from "../../domain/result.js"; +it.each([ + { path: "relative" }, + { path: "/module", glue_paths: ["relative"] }, + { path: "/module", candidate_paths: ["relative"] }, + { path: "/module", fetch: true }, +])("rejects invalid selection before effects: %j", async (input) => { + const service = new WasmArtifactService({ + close: () => Promise.resolve(), + inspect: () => { + throw new Error("Must not execute"); + }, + }); + expect(await service.inspect(input)).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); +}); +it.each(["path", "digest", "wat", "candidates", "glue"])( + "rejects mismatched upstream observation: %s", + async (problem) => { + const value = wasmArtifactFixture(); + if (problem === "path") value.artifact.path = "/different"; + if (problem === "wat") value.wat.sha256 = "b".repeat(64); + if (problem === "candidates") value.candidates = []; + const execution = wasmArtifactExecution(value); + const service = new WasmArtifactService({ + close: () => Promise.resolve(), + inspect: () => + Promise.resolve( + ok({ + ...execution, + subject: { + path: "/artifacts/module.wasm", + format: "file", + sha256: + problem === "digest" ? "b".repeat(64) : value.artifact.sha256, + }, + }), + ), + }); + expect( + await service.inspect({ + path: "/artifacts/module.wasm", + ...(problem === "glue" ? { glue_paths: ["/glue.js"] } : {}), + }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } }); + }, +); +it("cancels before provider work and delivers observed Evidence on success", async () => { + const value = wasmArtifactFixture(); + let calls = 0; + const service = new WasmArtifactService({ + close: () => Promise.resolve(), + inspect: () => { + calls++; + return Promise.resolve(ok(wasmArtifactExecution(value))); + }, + }); + const controller = new AbortController(); + controller.abort(); + expect( + await service.inspect( + { path: value.artifact.path }, + { signal: controller.signal }, + ), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } }); + expect(calls).toBe(0); + expect(await service.inspect({ path: value.artifact.path })).toMatchObject({ + ok: true, + value: { confidence: "observed", normalized_result: value }, + }); +}); diff --git a/src/application/wasm/WasmArtifactService.ts b/src/application/wasm/WasmArtifactService.ts new file mode 100644 index 000000000..a59b66f6b --- /dev/null +++ b/src/application/wasm/WasmArtifactService.ts @@ -0,0 +1,100 @@ +import { createHash } from "node:crypto"; +import { isAbsolute } from "node:path"; +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { WasmArtifactPort } from "./WasmArtifactPort.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { + inspectWasmArtifactInputSchema, + wasmArtifactSchema, +} from "../../domain/wasm/wasmArtifact.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { err, ok, type Result } from "../../domain/result.js"; + +const OPERATION = "inspect_wasm_artifact"; + +/** Shared artifact validation and typed inline Evidence for CLI/MCP. */ +export class WasmArtifactService { + constructor(readonly provider: WasmArtifactPort) {} + + /** Join provider cleanup during shutdown or an explicit retry. */ + close(): Promise { + return this.provider.close(); + } + + /** Preserve exact selected artifact identity and validated upstream observations. */ + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); + const input = inspectWasmArtifactInputSchema.safeParse(rawInput); + if (!input.success) + return err( + analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, { + cause: input.error, + }), + ); + if ( + ![ + input.data.path, + ...input.data.glue_paths, + ...input.data.candidate_paths, + ].every(isAbsolute) + ) + return err( + new AnalysisInputError(OPERATION, undefined, [ + { + path: ["path"], + reason: "invalid_format", + message: "Expected an absolute filesystem path on this host.", + }, + ]), + ); + const inspected = await this.provider.inspect(input.data, options); + if (!inspected.ok) return inspected; + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); + const execution = inspected.value; + const report = wasmArtifactSchema.safeParse(execution.result); + if ( + !report.success || + report.data.artifact.path !== input.data.path || + execution.subject?.path !== input.data.path || + execution.subject.sha256 !== report.data.artifact.sha256 || + createHash("sha256").update(report.data.wat.text).digest("hex") !== + report.data.wat.sha256 || + JSON.stringify(report.data.glue.map((item) => item.artifact.path)) !== + JSON.stringify(input.data.glue_paths) || + JSON.stringify(report.data.candidates.map((item) => item.path)) !== + JSON.stringify([ + ...new Set([input.data.path, ...input.data.candidate_paths]), + ]) + ) + return err( + new AnalysisOutputError( + OPERATION, + "WABT adapter returned malformed data or changed the selected artifact identity.", + ), + ); + return ok( + createEvidence(execution.subject, execution.provider, { + operation: OPERATION, + parameters: input.data, + result: report.data, + ...(execution.rawResult === null + ? {} + : { rawResult: execution.rawResult }), + confidence: "observed", + locations: execution.locations, + limitations: execution.limitations, + }), + ); + } +} diff --git a/src/artifacts/MachOSliceArtifactReader.ts b/src/artifacts/MachOSliceArtifactReader.ts index 115ffae79..f9e179981 100644 --- a/src/artifacts/MachOSliceArtifactReader.ts +++ b/src/artifacts/MachOSliceArtifactReader.ts @@ -301,7 +301,7 @@ export class MachOSliceArtifactReader implements ArtifactReader { } async #releaseResources(): Promise { - await Promise.allSettled([...this.#pendingCommands]); + await Promise.allSettled(this.#pendingCommands); let cleanup: ArtifactReaderFailure["cleanup"]; try { const failures = (await this.runner.retryCleanup?.()) ?? []; diff --git a/src/cli.ts b/src/cli.ts index a9ac2fa54..2d520f33d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,3 +1,4 @@ +import { registerWasmCommands } from "./cli/wasmCommands.js"; import { snapshotEnvironment } from "./process/snapshotEnvironment.js"; import { createDirectAnalysis } from "./composition/directAnalysis.js"; import { registerEvmCommands } from "./cli/evmCommands.js"; @@ -72,6 +73,7 @@ export const createCli = ( registerSetupCommands(cli, logger, environment); registerCoreAnalysisCommands(cli, logger, analysis.runDirectAnalysis); registerUtilityCommands(cli, logger, environment, analysis); + registerWasmCommands(cli, logger, environment); registerEvmCommands(cli, logger, environment); registerArtifactCommands(cli, logger, analysis.runProviderAnalysis); registerManagedCommands(cli, logger, analysis.runProviderAnalysis); diff --git a/src/cli/wasmCommands.ts b/src/cli/wasmCommands.ts new file mode 100644 index 000000000..a6b32b18f --- /dev/null +++ b/src/cli/wasmCommands.ts @@ -0,0 +1,60 @@ +import { z } from "incur"; +import { resolve } from "node:path"; +import { createWasmArtifactService } from "../composition/wasm.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import type { Logger } from "pino"; +import type { CliInstance } from "./types.js"; +import { withCommandCancellation } from "./commandCancellation.js"; + +/** Resolve all caller-selected artifact paths through the shared workflow. */ +export const registerWasmCommands = ( + cli: CliInstance, + logger: Logger, + environment: Readonly, +): void => { + const service = createWasmArtifactService(environment); + cli.command(CLI_COMMANDS.inspectWasmArtifact, { + description: "Inspect an explicit local WASM artifact with WABT", + args: z.object({ + path: z.string().describe("Local WASM artifact"), + }), + options: z.object({ + glue_paths: z + .array(z.string()) + .optional() + .describe("Selected local JavaScript glue paths"), + candidate_paths: z + .array(z.string()) + .optional() + .describe("Explicit local WASM candidates"), + }), + run: ({ args, options }) => + withCommandCancellation(async (signal) => { + try { + return await logCliCommand( + logger, + CLI_COMMANDS.inspectWasmArtifact, + async () => { + const result = await service.inspect( + { + path: resolve(args.path), + glue_paths: options.glue_paths?.map((path) => resolve(path)), + candidate_paths: options.candidate_paths?.map((path) => + resolve(path), + ), + }, + { signal }, + ); + return result.ok + ? result.value + : projectAnalysisError(result.error); + }, + ); + } finally { + await service.close(); + } + }), + }); +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 507ac9156..8a8cc2b66 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -1,5 +1,6 @@ /** Canonical names used by every one-shot CLI command registration. */ export const CLI_COMMANDS = Object.freeze({ + inspectWasmArtifact: "inspect-wasm-artifact", inspectEvmInterface: "inspect-evm-interface", inspectWebNetworkCapture: "inspect-web-network-capture", observeWebExecution: "observe-web-execution", diff --git a/src/composition/wasm.ts b/src/composition/wasm.ts new file mode 100644 index 000000000..c8a67b388 --- /dev/null +++ b/src/composition/wasm.ts @@ -0,0 +1,7 @@ +import { WasmArtifactService } from "../application/wasm/WasmArtifactService.js"; +import { WabtArtifactProvider } from "../wasm/WabtArtifactProvider.js"; +/** Compose the caller-supplied offline toolchain without acquisition or import-time I/O. */ +export const createWasmArtifactService = ( + environment: Readonly = process.env, +): WasmArtifactService => + new WasmArtifactService(new WabtArtifactProvider(environment)); diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index df055b2f5..9361cbffc 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -1,3 +1,4 @@ +import { WASM_TOOL_CONTRACTS } from "./wasm/wasmToolContracts.js"; import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js"; import { ANALYSIS_VIEW_TOOL_CONTRACTS } from "./analysisViewToolContracts.js"; import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js"; @@ -31,6 +32,7 @@ export const TOOL_CONTRACTS = [ ...ANALYSIS_VIEW_TOOL_CONTRACTS, ...ARTIFACT_TOOL_CONTRACTS, ...EVM_TOOL_CONTRACTS, + ...WASM_TOOL_CONTRACTS, ...MANAGED_TOOL_CONTRACTS, ...ANDROID_TOOL_CONTRACTS, ...APKTOOL_TOOL_CONTRACTS, diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 4ef01c9ac..186b7bae7 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -41,6 +41,11 @@ const sessionEvidence = effects({ mutatesSession: true }); /** Explicit effect audit for every public tool. */ export const TOOL_EFFECTS: Readonly> = { + inspect_wasm_artifact: effects({ + mutatesSession: true, + writesFilesystem: true, + launchesProcess: true, + }), inspect_evm_interface: effects({ mutatesSession: true, writesFilesystem: true, diff --git a/src/contracts/wasm/wasmToolContracts.ts b/src/contracts/wasm/wasmToolContracts.ts new file mode 100644 index 000000000..53934c643 --- /dev/null +++ b/src/contracts/wasm/wasmToolContracts.ts @@ -0,0 +1,29 @@ +import { + inspectWasmArtifactInputSchema, + wasmArtifactSchema, +} from "../../domain/wasm/wasmArtifact.js"; +import type { ToolContract } from "../toolContractTypes.js"; +import { toolContractMetadata } from "../toolEffects.js"; +import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; +/** Optional caller-supplied WABT capability with target-free CLI/MCP parity. */ +export const WASM_TOOL_CONTRACTS = [ + { + name: "inspect_wasm_artifact", + ...toolContractMetadata("inspect_wasm_artifact"), + kind: "artifact-provider", + description: + "Validate an explicit local WASM artifact with caller-supplied WABT 1.0.42, retain exact byte SHA-256, sections, upstream import/export WAT forms and decoded WAT inline as Evidence. Optionally associate selected JavaScript glue literals with explicit local candidates while preserving ambiguous URL/basename matches. Configure absolute REA_WABT_BIN_DIRECTORY. No installation, implicit fetch, WASM execution or original-source claim.", + inputSchema: inspectWasmArtifactInputSchema, + outputSchema: evidenceResultOf(wasmArtifactSchema), + examples: [ + { + title: "Inspect a selected local WASM module", + input: { + path: "/artifacts/module.wasm", + glue_paths: [], + candidate_paths: [], + }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/domain/canonicalJsonDigestSteps.test.ts b/src/domain/canonicalJsonDigestSteps.test.ts index 8ff3382c5..a15893fdc 100644 --- a/src/domain/canonicalJsonDigestSteps.test.ts +++ b/src/domain/canonicalJsonDigestSteps.test.ts @@ -23,7 +23,7 @@ describe("cooperative canonical JSON digest", () => { { [`${"x".repeat(8191)}𐀀${"\n".repeat(20_000)}`]: "value" }, Object.fromEntries( Array.from({ length: 512 }, (_, index) => [ - `escaped\n\"\\𐀀${"x".repeat(32)}${index}`, + `escaped\n"\\𐀀${"x".repeat(32)}${index}`, { repeated: index }, ]), ), diff --git a/src/domain/wasm/wasmArtifact.ts b/src/domain/wasm/wasmArtifact.ts new file mode 100644 index 000000000..70a86b79a --- /dev/null +++ b/src/domain/wasm/wasmArtifact.ts @@ -0,0 +1,126 @@ +import { z } from "zod"; + +const digest = z.string().regex(/^[a-f0-9]{64}$/); +const artifact = z.strictObject({ + path: z.string().min(1), + sha256: digest, + bytes: z.number().int().nonnegative(), +}); +/** All artifacts are explicitly selected local files; references never trigger acquisition. */ +export const inspectWasmArtifactInputSchema = z.strictObject({ + path: z + .string() + .min(1) + .describe("Absolute selected local WASM artifact path"), + glue_paths: z.array(z.string().min(1)).max(16).default([]), + candidate_paths: z.array(z.string().min(1)).max(16).default([]), +}); +/** Upstream output stays intact; import/export names are never split on ambiguous punctuation. */ +export const wasmArtifactSchema = z + .strictObject({ + artifact, + validation: z.literal("valid"), + sections: z.array( + z.strictObject({ + kind: z.string().min(1), + start: z.number().int().nonnegative(), + end: z.number().int().nonnegative(), + bytes: z.number().int().nonnegative(), + description: z.string(), + }), + ), + imports: z.array(z.string()), + exports: z.array(z.string()), + headers: z.string(), + details: z.string(), + wat: z.strictObject({ + text: z.string().min(1), + sha256: digest, + representation: z.literal("decoded-wat"), + }), + tool_profile: z.strictObject({ + source: z.literal("https://github.com/WebAssembly/wabt"), + release: z.literal("1.0.42"), + source_commit: z.literal("ff0ef7e0009402740c805a9744c09b05be063e48"), + commands: z + .array( + z.strictObject({ + tool: z.enum(["wasm-validate", "wasm-objdump", "wasm2wat"]), + path: z.string().min(1), + sha256: digest, + version_banner: z.string(), + arguments: z.array(z.string()), + }), + ) + .length(3), + timeout_ms: z.number().int().positive(), + output_budget_bytes: z.number().int().positive(), + }), + candidates: z.array(artifact), + glue: z.array( + z.strictObject({ + artifact, + parse_status: z.enum(["complete", "partial", "failed"]), + references: z.array( + z.strictObject({ + value: z.string(), + line: z.number().int().positive(), + column: z.number().int().nonnegative(), + candidate_paths: z.array(z.string()), + association: z.enum([ + "local-path-candidate", + "basename-candidates", + "unresolved", + ]), + evidence_kind: z.literal("static-literal"), + }), + ), + }), + ), + runtime_execution: z.literal("not-performed"), + network_fetch: z.literal("not-performed"), + limitations: z.array(z.string()), + }) + .superRefine((value, context) => { + const selected = value.candidates[0]; + if ( + selected?.path !== value.artifact.path || + selected.sha256 !== value.artifact.sha256 || + selected.bytes !== value.artifact.bytes + ) + context.addIssue({ + code: "custom", + path: ["candidates"], + message: "Selected candidate does not match artifact identity.", + }); + for (const [index, command] of value.tool_profile.commands.entries()) { + const expected = ["wasm-validate", "wasm-objdump", "wasm2wat"][index]; + const argumentsExpected = + command.tool === "wasm-objdump" + ? ["-h", "-x", "module.wasm"] + : ["--enable-all", "module.wasm"]; + if ( + command.tool !== expected || + command.version_banner.trim() !== "1.0.42" || + JSON.stringify(command.arguments) !== JSON.stringify(argumentsExpected) + ) + context.addIssue({ + code: "custom", + path: ["tool_profile", "commands", index], + message: "Unexpected WABT command profile.", + }); + } + for (const [index, section] of value.sections.entries()) + if ( + section.end > value.artifact.bytes || + section.end - section.start !== section.bytes + ) + context.addIssue({ + code: "custom", + path: ["sections", index], + message: "Section range does not match selected artifact bytes.", + }); + }); +export type InspectWasmArtifactInput = z.infer< + typeof inspectWasmArtifactInputSchema +>; diff --git a/src/generatedPackageMetadata.ts b/src/generatedPackageMetadata.ts index 89e454ec5..b4795e7d8 100644 --- a/src/generatedPackageMetadata.ts +++ b/src/generatedPackageMetadata.ts @@ -5,5 +5,5 @@ export const PACKAGE_METADATA = { serverSdkVersion: "2.3.1", clientSdkVersion: "2.3.1", coreSdkVersion: "2.3.1", - skillVersion: "37", + skillVersion: "38", } as const; diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 7535e5bbe..75227b02d 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -261,7 +261,7 @@ class NativeMacOSClient implements AnalysisClient { async close(): Promise> { this.#closed = true; - await Promise.allSettled([...this.#pendingCommands]); + await Promise.allSettled(this.#pendingCommands); const uiCleanupFailures = await this.#serializeNativeUi(() => this.#retryNativeUiCleanup(), ); diff --git a/src/server/createServer.ts b/src/server/createServer.ts index ff1a33835..ac29459f4 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -1,3 +1,6 @@ +import type { WasmArtifactService } from "../application/wasm/WasmArtifactService.js"; +import { createWasmArtifactService } from "../composition/wasm.js"; +import { registerWasmTools } from "./registerWasmTools.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { createPeResourcesService } from "../composition/binaryDiagnostics.js"; import { registerPeResourcesTool } from "./registerPeResourcesTool.js"; @@ -103,6 +106,7 @@ const ACTIVE_TARGET_INSTRUCTIONS = export interface CreateServerOptions { readonly environment?: Readonly; readonly delivery?: ToolResultDelivery; + readonly wasmArtifact?: WasmArtifactService; readonly evmInterface?: EvmInterfaceService; readonly logger?: Logger; readonly binaryLayout?: BinaryLayoutService; @@ -139,6 +143,9 @@ const installSessionToolAvailability = ( const policy = sessionAvailabilityPolicy(options.availabilityPolicy, { optionalProviderLoadFailures: options.optionalProviderLoadFailures, optionalFeatures: { + wasmArtifactEnabled: + options.wasmArtifact !== undefined || + isAbsolute(environment.REA_WABT_BIN_DIRECTORY ?? ""), evmInterfaceEnabled: options.evmInterface !== undefined || linuxX64, webModuleResolutionEnabled: options.webModuleTrace !== undefined || @@ -232,6 +239,8 @@ export const createServer = ( ); const android = options.androidAnalysis ?? createAndroidAnalysisProvider(environment); + const wasmArtifact = + options.wasmArtifact ?? createWasmArtifactService(environment); const apktool = options.apktoolAnalysis ?? createApktoolResourceAnalysisProvider(environment); @@ -242,6 +251,7 @@ export const createServer = ( const closeProviders = (): Promise => { providerClose ??= Promise.allSettled([ android.close(), + wasmArtifact.close(), apktool.close(), jeb.close(), adbDevice.close(), @@ -308,7 +318,13 @@ export const createServer = ( for (const result of results) if (result.status === "rejected") throw result.reason; }; - registerConfiguredAnalysisTools(toolContext, android, jeb, firmwareAnalysis); + registerConfiguredAnalysisTools( + toolContext, + android, + wasmArtifact, + jeb, + firmwareAnalysis, + ); registerAdbTools( server, new AdbDeviceAnalysisService(adbDevice), @@ -374,6 +390,7 @@ const registerConfiguredAnalysisTools = ( withAdmittedAnalysis, }: ServerToolContext, android: AndroidAnalysisPort, + wasmArtifact: WasmArtifactService, jeb: JebAnalysisPort, firmwareAnalysis: FirmwareAnalysisService, ): void => { @@ -413,6 +430,13 @@ const registerConfiguredAnalysisTools = ( recordEvidence, withAdmittedAnalysis, ); + registerWasmTools( + server, + wasmArtifact, + toolLogger, + recordEvidence, + withAdmittedAnalysis, + ); registerEvmTools( server, options.evmInterface ?? createEvmInterfaceService(environment), diff --git a/src/server/registerWasmTools.ts b/src/server/registerWasmTools.ts new file mode 100644 index 000000000..7aebed5b2 --- /dev/null +++ b/src/server/registerWasmTools.ts @@ -0,0 +1,32 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; + +import type { WasmArtifactService } from "../application/wasm/WasmArtifactService.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { Logger } from "pino"; +import { createEvidenceToolHandler } from "./contractToolHandler.js"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Bind the named WASM contract to shared validation and session Evidence recording. */ +export const registerWasmTools = ( + server: EvidenceMcpServer, + service: WasmArtifactService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, +): void => { + const contract = toolContract("inspect_wasm_artifact"); + const handler = createEvidenceToolHandler( + server, + (input, options) => service.inspect(input, options), + logger, + recordEvidence, + withAdmittedAnalysis, + ); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + handler(contract), + ); +}; diff --git a/src/server/sessionAvailabilityPolicy.ts b/src/server/sessionAvailabilityPolicy.ts index 14996091d..e3318a1c5 100644 --- a/src/server/sessionAvailabilityPolicy.ts +++ b/src/server/sessionAvailabilityPolicy.ts @@ -8,6 +8,7 @@ export interface SessionAvailabilityDefaults { | undefined; readonly optionalFeatures?: Pick< AvailabilityPolicy, + | "wasmArtifactEnabled" | "evmInterfaceEnabled" | "browserObservationEnabled" | "browserScenarioEnabled" @@ -31,6 +32,8 @@ export const sessionAvailabilityPolicy = ( const policy = configured ?? (() => ({ + wasmArtifactEnabled: + defaults.optionalFeatures?.wasmArtifactEnabled ?? false, evmInterfaceEnabled: defaults.optionalFeatures?.evmInterfaceEnabled ?? false, processCaptureEnabled: platform !== "win32", diff --git a/src/wasm/WabtArtifactProvider.test.ts b/src/wasm/WabtArtifactProvider.test.ts new file mode 100644 index 000000000..5a9e1cc45 --- /dev/null +++ b/src/wasm/WabtArtifactProvider.test.ts @@ -0,0 +1,194 @@ +import { access, chmod, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { WabtArtifactProvider } from "./WabtArtifactProvider.js"; +import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; +import { spawnOwnedProviderProcess } from "../process/ProviderProcess.js"; +import { OwnedCommandFailure } from "../process/OwnedCommand.js"; + +const failureTags: Readonly> = { + invalid: "AnalysisInputError", + timeout: "AnalysisTimeoutError", + cancelled: "AnalysisCancelledError", + "changed-snapshot": "AnalysisArtifactChangedError", + cleanup: "ProviderAdapterError", + malformed: "AnalysisOutputError", + "malformed-wat": "AnalysisOutputError", + "wat-warning": "AnalysisOutputError", +}; + +it("returns actionable unavailable without creating a workspace when tools are absent", async () => { + const provider = new WabtArtifactProvider({}, undefined, () => { + throw new Error("Must not allocate"); + }); + expect( + await provider.inspect({ + path: "/module.wasm", + glue_paths: [], + candidate_paths: [], + }), + ).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + userMessage: expect.stringContaining("REA_WABT_BIN_DIRECTORY"), + }, + }); +}); +it.each([ + "success", + "invalid", + "malformed", + "timeout", + "cancelled", + "cleanup", + "changed-snapshot", + "malformed-wat", + "wat-warning", +])("owns private artifact bytes and retains %s outcome", async (mode) => { + const selectedRoot = await createTestTempDirectory("rea-wabt-seam-"); + const path = join(selectedRoot, "selected.wasm"); + const bytes = Buffer.alloc(76, 1); + await writeFile(path, bytes); + for (const tool of ["wasm-validate", "wasm-objdump", "wasm2wat"]) + await writeFile( + join(selectedRoot, tool + (process.platform === "win32" ? ".exe" : "")), + "source-owned executable seam", + { mode: 0o700 }, + ); + const dump = await readFile( + new URL("../../tests/fixtures/wasm/objdump.txt", import.meta.url), + "utf8", + ); + let runtimePath = ""; + let acquiredPid: number | undefined; + let cleanupAttempts = 0; + const controller = new AbortController(); + const provider = new WabtArtifactProvider( + { REA_WABT_BIN_DIRECTORY: selectedRoot }, + async (input) => { + const version = input.arguments[0] === "--version"; + if (!version) { + expect(await readFile(join(input.cwd ?? "", "module.wasm"))).toEqual( + bytes, + ); + if (mode === "changed-snapshot") { + const snapshot = join(input.cwd ?? "", "module.wasm"); + await chmod(snapshot, 0o600); + await writeFile(snapshot, Buffer.alloc(76, 2)); + } + if (mode === "timeout") + throw new OwnedCommandFailure( + "timeout", + "Source-owned deadline seam", + ); + } + const stdout = version + ? "1.0.42\n" + : input.command.includes("objdump") + ? mode === "malformed" + ? "unexpected producer output" + : dump + : input.command.includes("wasm2wat") + ? mode === "malformed-wat" + ? "unexpected decoder output\n" + : '(module (@custom "test" "payload"))\n' + : ""; + const invalid = !version && mode === "invalid"; + const stderr = invalid + ? "module.wasm: invalid bytes\n" + : !version && + input.command.includes("wasm2wat") && + mode === "wat-warning" + ? "decoder warning\n" + : ""; + const script = + !version && mode === "cancelled" + ? "setInterval(() => undefined, 1000)" + : `process.stdout.write(${JSON.stringify(stdout)}); process.stderr.write(${JSON.stringify(stderr)}); process.exit(${invalid ? 1 : 0});`; + const launched = await spawnOwnedProviderProcess({ + ...input, + command: process.execPath, + arguments: ["-e", script], + expectedCommand: null, + }); + if (!version && mode === "cancelled") { + acquiredPid = launched.process.pid; + setImmediate(() => controller.abort()); + } + return launched; + }, + async () => { + const root = await PrivateRuntimeRoot.create({ + prefix: "rea-wabt-owned-test-", + }); + runtimePath = root.path; + return { + path: root.path, + close: async () => { + if (mode === "cleanup" && ++cleanupAttempts <= 2) + throw new Error("Source-owned cleanup uncertainty"); + await root.close(); + }, + }; + }, + ); + const result = await provider.inspect( + { path, glue_paths: [], candidate_paths: [] }, + { signal: controller.signal }, + ); + if (mode === "success") + expect(result).toMatchObject({ + ok: true, + value: { + result: { + artifact: { path, bytes: 76 }, + validation: "valid", + runtime_execution: "not-performed", + }, + }, + }); + else + expect(result).toMatchObject({ + ok: false, + error: { _tag: failureTags[mode] }, + }); + if (mode === "invalid") + expect(result).toMatchObject({ + error: { capturedOutput: { stderr: "module.wasm: invalid bytes\n" } }, + }); + if (mode === "cleanup") { + expect(result).toMatchObject({ + error: { + cleanupIncomplete: true, + cleanupResources: [runtimePath], + partialObservation: { normalized_result: { validation: "valid" } }, + }, + }); + await expect(access(runtimePath)).resolves.toBeUndefined(); + await expect(provider.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + await expect(access(runtimePath)).resolves.toBeUndefined(); + await provider.close(); + } + if (mode === "malformed-wat" || mode === "wat-warning") + expect(result).toMatchObject({ + error: { + capturedOutput: { + stdout: + mode === "malformed-wat" + ? "unexpected decoder output\n" + : '(module (@custom "test" "payload"))\n', + stderr: mode === "wat-warning" ? "decoder warning\n" : "", + }, + }, + }); + await expect(access(runtimePath)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path)).toEqual(bytes); + if (acquiredPid !== undefined) { + const pid = acquiredPid; + expect(() => process.kill(pid, 0)).toThrow(); + } +}); diff --git a/src/wasm/WabtArtifactProvider.ts b/src/wasm/WabtArtifactProvider.ts new file mode 100644 index 000000000..6ffaa04a9 --- /dev/null +++ b/src/wasm/WabtArtifactProvider.ts @@ -0,0 +1,439 @@ +import { randomUUID, createHash } from "node:crypto"; +import { access, realpath, writeFile } from "node:fs/promises"; +import { constants } from "node:fs"; +import { isAbsolute, join } from "node:path"; +import type { + AnalysisExecution, + ExecutionOptions, +} from "../application/AnalysisProvider.js"; +import { createAnalysisExecution } from "../application/AnalysisProvider.js"; +import type { WasmArtifactPort } from "../application/wasm/WasmArtifactPort.js"; +import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import type { + AnalysisError, + AnalysisCapturedOutput, +} from "../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisArtifactChangedError, + AnalysisInputError, + AnalysisOutputError, +} from "../domain/analysisErrorCore.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { createEvidence } from "../domain/evidence.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { + wasmArtifactSchema, + type InspectWasmArtifactInput, +} from "../domain/wasm/wasmArtifact.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { runOwnedCommand } from "../process/OwnedCommand.js"; +import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; +import { WABT_PROVIDER_IDENTITY, WABT_LIMITS } from "./WabtRelease.js"; +import { wabtFailure } from "./WabtFailures.js"; +import { parseWabtOutput } from "./WabtOutput.js"; +import { parseWabtWat } from "./WabtWat.js"; +import { associateWasmGlue } from "./WasmGlueAdapter.js"; + +type Launcher = NonNullable[2]>["launcher"]; +const OPERATION = "inspect_wasm_artifact"; +const digest = (text: string) => + createHash("sha256").update(text).digest("hex"); +/** Run caller-supplied WABT against private copies of exactly selected bytes. */ +export class WabtArtifactProvider implements WasmArtifactPort { + readonly #pendingRoots = new Set< + Pick + >(); + constructor( + readonly environment: Readonly = process.env, + readonly launcher?: Launcher, + readonly createRuntime: () => Promise< + Pick + > = () => PrivateRuntimeRoot.create({ prefix: "rea-wabt-" }), + ) {} + /** Retry retained cleanup without discarding roots that still cannot be removed. */ + async close(): Promise { + const failures: { path: string; reason: string }[] = []; + for (const root of this.#pendingRoots) { + try { + await root.close(); + this.#pendingRoots.delete(root); + } catch (cause: unknown) { + failures.push({ + path: root.path, + reason: cause instanceof Error ? cause.message : String(cause), + }); + } + } + if (failures.length > 0) + throw new ProviderCleanupError( + WABT_PROVIDER_IDENTITY.id, + failures.map(({ path }) => path), + { failures }, + { operation: OPERATION }, + ); + } + async inspect( + input: InspectWasmArtifactInput, + options?: ExecutionOptions, + ): Promise> { + let root: Pick | undefined; + let phase: "configuration" | "artifact" | "producer" = "configuration"; + let result: Result; + let capturedOutput: AnalysisCapturedOutput | undefined; + try { + if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION); + await this.close(); + const directory = this.environment.REA_WABT_BIN_DIRECTORY ?? ""; + if (!isAbsolute(directory)) + throw new Error( + "REA_WABT_BIN_DIRECTORY must be an absolute tool directory.", + ); + const commands: { + tool: string; + path: string; + sha256: string; + version_banner: string; + arguments: string[]; + }[] = []; + for (const tool of ["wasm-validate", "wasm-objdump", "wasm2wat"]) { + const path = await realpath( + join( + directory, + `${tool}${process.platform === "win32" ? ".exe" : ""}`, + ), + ); + await access(path, constants.X_OK); + const binary = await readStableArtifact( + path, + WABT_LIMITS.toolBytes, + options?.signal, + ); + commands.push({ + tool, + path, + sha256: binary.sha256, + version_banner: "", + arguments: + tool === "wasm-objdump" + ? ["-h", "-x", "module.wasm"] + : ["--enable-all", "module.wasm"], + }); + } + root = await this.createRuntime(); + const snapshotPath = join(root.path, "module.wasm"); + let snapshotHash: string | undefined; + const verifySnapshot = async () => { + if (snapshotHash === undefined) return; + try { + const bytes = await readStableArtifact( + snapshotPath, + WABT_LIMITS.inputBytes, + options?.signal, + ); + if (bytes.sha256 !== snapshotHash) + throw new Error("Private WASM snapshot bytes changed."); + } catch (cause: unknown) { + if (options?.signal?.aborted) + throw new AnalysisCancelledError(OPERATION); + throw new AnalysisArtifactChangedError( + OPERATION, + input.path, + "Private WASM snapshot no longer matches selected byte identity.", + { cause }, + ); + } + }; + const execute = async ( + command: (typeof commands)[number], + args: readonly string[], + acceptNonZeroExit = false, + ) => { + await verifySnapshot(); + const snapshot = await runOwnedCommand( + { + command: command.path, + arguments: args, + ...(root === undefined ? {} : { cwd: root.path }), + expectedCommand: command.path, + runId: `rea-wabt-${randomUUID()}`, + hostEnvironment: this.environment, + }, + { + timeoutMs: WABT_LIMITS.timeoutMs, + diagnosticBytes: WABT_LIMITS.outputBytes, + acceptNonZeroExit, + }, + { + ...(options?.signal === undefined + ? {} + : { signal: options.signal }), + ...(this.launcher === undefined ? {} : { launcher: this.launcher }), + }, + ); + const current = await readStableArtifact( + command.path, + WABT_LIMITS.toolBytes, + options?.signal, + ); + if (current.sha256 !== command.sha256) + throw new AnalysisOutputError( + OPERATION, + "Configured WABT executable changed during inspection.", + ); + await verifySnapshot(); + return snapshot; + }; + for (const command of commands) { + const version = await execute(command, ["--version"]); + if ( + version.stdout.text.trim() !== "1.0.42" || + version.stderr.text !== "" + ) + throw new Error( + `Unsupported ${command.tool} version banner; expected 1.0.42.`, + ); + command.version_banner = version.stdout.text; + } + phase = "artifact"; + const selected = await readStableArtifact( + input.path, + WABT_LIMITS.inputBytes, + options?.signal, + ); + const artifact = { + path: input.path, + sha256: selected.sha256, + bytes: selected.bytes.length, + }; + const candidates = []; + for (const path of new Set([input.path, ...input.candidate_paths])) { + const bytes = + path === input.path + ? selected + : await readStableArtifact( + path, + WABT_LIMITS.inputBytes, + options?.signal, + ); + candidates.push({ + path, + sha256: bytes.sha256, + bytes: bytes.bytes.length, + }); + } + const glue = []; + for (const path of input.glue_paths) { + const bytes = await readStableArtifact( + path, + WABT_LIMITS.inputBytes, + options?.signal, + ); + const source = new TextDecoder("utf-8", { fatal: true }).decode( + bytes.bytes, + ); + glue.push({ + artifact: { path, sha256: bytes.sha256, bytes: bytes.bytes.length }, + ...associateWasmGlue( + source, + path, + candidates.map((candidate) => candidate.path), + ), + }); + } + await writeFile(snapshotPath, selected.bytes, { + flag: "wx", + mode: 0o400, + ...(options?.signal === undefined ? {} : { signal: options.signal }), + }); + snapshotHash = selected.sha256; + phase = "producer"; + const [validate, objdump, decode] = commands; + if ( + validate === undefined || + objdump === undefined || + decode === undefined + ) + throw new Error("Incomplete WABT tool profile."); + const validation = await execute(validate, validate.arguments, true); + if (validation.exitCode !== 0) + throw new AnalysisInputError( + OPERATION, + { + partialObservation: createEvidence( + { path: input.path, format: "file", sha256: selected.sha256 }, + WABT_PROVIDER_IDENTITY, + { + operation: OPERATION, + parameters: input, + result: { + artifact, + validation: "rejected-by-selected-profile", + tool_profile: { + source: "https://github.com/WebAssembly/wabt", + release: "1.0.42", + source_commit: "ff0ef7e0009402740c805a9744c09b05be063e48", + commands, + }, + exit_code: validation.exitCode ?? null, + stdout: validation.stdout.text, + stderr: validation.stderr.text, + }, + confidence: "observed", + locations: [{ kind: "artifact-path", path: input.path }], + limitations: [ + "WABT rejected these exact selected bytes with --enable-all; no complete inspection or target execution occurred.", + ], + }, + ), + capturedOutput: { + stdout: validation.stdout.text, + stderr: validation.stderr.text, + truncated: false, + }, + }, + [ + { + path: ["path"], + reason: "invalid_format", + message: + "Selected bytes failed WABT validation with --enable-all; inspect retained upstream diagnostics.", + }, + ], + ); + const dump = await execute(objdump, objdump.arguments); + capturedOutput = { + stdout: dump.stdout.text, + stderr: dump.stderr.text, + truncated: false, + }; + if (dump.stderr.text !== "") + throw new AnalysisOutputError( + OPERATION, + "Unexpected WABT section output.", + ); + const wat = await execute(decode, decode.arguments); + capturedOutput = { + stdout: wat.stdout.text, + stderr: wat.stderr.text, + truncated: false, + }; + if (!wat.stdout.text.startsWith("(module") || wat.stderr.text !== "") + throw new AnalysisOutputError( + OPERATION, + "Unexpected WABT decoding output.", + ); + const { imports, exports, customSectionNames } = parseWabtWat( + wat.stdout.text, + ); + capturedOutput = { + stdout: dump.stdout.text, + stderr: dump.stderr.text, + truncated: false, + }; + const { sections, headers, details } = parseWabtOutput( + dump.stdout.text, + artifact.bytes, + customSectionNames, + ); + const limitations = [ + "WAT is a decoded representation, not original source or proof of runtime behavior. Custom annotations may retain payloads, but WAT does not establish original binary layout or round-trip byte identity. Original bytes and objdump output remain authoritative.", + "Imports and exports retain exact top-level WABT WAT forms. Names remain escaped producer text; unescaped objdump display names are not parsed as unambiguous identifiers.", + "JavaScript references are AST-observed static literals only. Candidate matches are associations, not proven data flow, URL-to-file identity or runtime loading. Computed references and aliases are not resolved.", + "No target execution or implicit fetch. WABT tools are trusted caller-supplied executables; SHA-256 identifies their bytes, not publisher authenticity. No OS memory sandbox is claimed.", + "Each subprocess has a 30-second deadline and 32 MiB combined output budget. Oversized output fails instead of producing truncated success.", + ]; + const report = wasmArtifactSchema.parse({ + artifact, + validation: "valid", + sections, + imports, + exports, + headers, + details, + wat: { + text: wat.stdout.text, + sha256: digest(wat.stdout.text), + representation: "decoded-wat", + }, + tool_profile: { + source: "https://github.com/WebAssembly/wabt", + release: "1.0.42", + source_commit: "ff0ef7e0009402740c805a9744c09b05be063e48", + commands, + timeout_ms: WABT_LIMITS.timeoutMs, + output_budget_bytes: WABT_LIMITS.outputBytes, + }, + candidates, + glue, + runtime_execution: "not-performed", + network_fetch: "not-performed", + limitations, + }); + result = ok( + createAnalysisExecution(report, WABT_PROVIDER_IDENTITY, { + subject: { + path: input.path, + format: "file", + sha256: selected.sha256, + }, + locations: [{ kind: "artifact-path", path: input.path }], + limitations, + }), + ); + } catch (cause: unknown) { + const failure = wabtFailure(cause, phase, input.path); + result = err( + failure instanceof AnalysisOutputError && + failure.capturedOutput === undefined && + capturedOutput !== undefined + ? new AnalysisOutputError(OPERATION, failure.reason, { + cause, + capturedOutput, + }) + : failure, + ); + } + if (root !== undefined) { + try { + await root.close(); + } catch (cause: unknown) { + this.#pendingRoots.add(root); + const partialObservation = result.ok + ? createEvidence( + result.value.subject ?? undefined, + result.value.provider, + { + operation: OPERATION, + parameters: input, + result: result.value.result, + confidence: "observed", + locations: result.value.locations, + limitations: result.value.limitations, + }, + ) + : result.error.partialObservation; + result = err( + new ProviderCleanupError( + WABT_PROVIDER_IDENTITY.id, + [root.path], + { + reason: cause instanceof Error ? cause.message : String(cause), + previous_error: result.ok + ? null + : projectAnalysisError(result.error), + }, + { + operation: OPERATION, + cause, + ...(partialObservation === undefined + ? {} + : { partialObservation }), + }, + ), + ); + } + } + return result; + } +} diff --git a/src/wasm/WabtFailures.ts b/src/wasm/WabtFailures.ts new file mode 100644 index 000000000..520fa9ed3 --- /dev/null +++ b/src/wasm/WabtFailures.ts @@ -0,0 +1,111 @@ +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisArtifactChangedError, + AnalysisAccessDeniedError, + AnalysisInputError, + AnalysisOutputError, + AnalysisTimeoutError, + AnalysisCapabilityUnavailableError, +} from "../domain/analysisErrorCore.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { OwnedCommandFailure } from "../process/OwnedCommand.js"; +import { WABT_LIMITS, WABT_PROVIDER_IDENTITY } from "./WabtRelease.js"; +/** Preserve lifecycle failures and diagnostic bytes instead of returning partial success. */ +export const wabtFailure = ( + cause: unknown, + phase: "configuration" | "artifact" | "producer", + path: string, +): AnalysisError => { + if (cause instanceof AnalysisError) return cause; + const reason = cause instanceof Error ? cause.message : String(cause); + if ( + (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") || + (cause instanceof Error && cause.name === "AbortError") + ) + return new AnalysisCancelledError("inspect_wasm_artifact", { cause }); + if (cause instanceof OwnedCommandFailure) { + const capturedOutput = + cause.snapshot === null + ? undefined + : { + stdout: cause.snapshot.stdout.text, + stderr: cause.snapshot.stderr.text, + truncated: cause.snapshot.diagnosticTruncated ?? false, + }; + const options = + capturedOutput === undefined ? { cause } : { cause, capturedOutput }; + if (cause.cleanupFailure !== null) + return new ProviderCleanupError( + WABT_PROVIDER_IDENTITY.id, + cause.resources, + { + reason: cause.cleanupFailure, + previous_error: reason, + ...(capturedOutput === undefined + ? {} + : { captured_output: capturedOutput }), + }, + { operation: "inspect_wasm_artifact", cause }, + ); + if (cause.reason === "cancelled") + return new AnalysisCancelledError("inspect_wasm_artifact", options); + if (cause.reason === "timeout") + return new AnalysisTimeoutError( + "inspect_wasm_artifact", + WABT_LIMITS.timeoutMs, + options, + ); + if (phase === "configuration" && cause.reason === "process") + return new AnalysisCapabilityUnavailableError( + WABT_PROVIDER_IDENTITY.id, + "inspect_wasm_artifact", + reason, + { + ...options, + userMessage: + "Configured WABT could not start. Check executable/interpreter access and WABT 1.0.42 profile; inspect retained diagnostics.", + }, + ); + return new AnalysisOutputError("inspect_wasm_artifact", reason, options); + } + if (phase === "configuration") + return new AnalysisCapabilityUnavailableError( + WABT_PROVIDER_IDENTITY.id, + "inspect_wasm_artifact", + reason, + { + cause, + userMessage: `${reason} Supply WABT 1.0.42 via absolute REA_WABT_BIN_DIRECTORY; REA does not install tools.`, + }, + ); + if ( + phase === "artifact" && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) + return new AnalysisArtifactChangedError( + "inspect_wasm_artifact", + path, + reason, + { cause }, + ); + if ( + phase === "artifact" && + cause instanceof Error && + "code" in cause && + (cause.code === "EACCES" || cause.code === "EPERM") + ) + return new AnalysisAccessDeniedError( + "inspect_wasm_artifact", + path, + cause.code, + { cause }, + ); + if (phase === "artifact") + return new AnalysisInputError("inspect_wasm_artifact", { cause }, [ + { path: ["path"], reason: "invalid_value", message: reason }, + ]); + return new AnalysisOutputError("inspect_wasm_artifact", reason, { cause }); +}; diff --git a/src/wasm/WabtOutput.test.ts b/src/wasm/WabtOutput.test.ts new file mode 100644 index 000000000..b593a88df --- /dev/null +++ b/src/wasm/WabtOutput.test.ts @@ -0,0 +1,92 @@ +import { readFile } from "node:fs/promises"; +import { expect, it } from "vitest"; +import { parseWabtOutput } from "./WabtOutput.js"; +import { parseWabtWat } from "./WabtWat.js"; +import { associateWasmGlue } from "./WasmGlueAdapter.js"; +const golden = () => + readFile( + new URL("../../tests/fixtures/wasm/objdump.txt", import.meta.url), + "utf8", + ); +it("retains real WABT section ranges, custom section identity and original header output", async () => { + const text = await golden(); + const headers = text.slice(0, text.indexOf("Section Details:\n")); + expect(parseWabtOutput(text, 76, ["test"]).sections).toHaveLength(7); + expect(parseWabtOutput(text, 76, ["test"]).headers).toBe(headers); + expect(parseWabtOutput(text, 76, ["test"]).sections.at(-1)).toEqual({ + kind: "Custom", + start: 64, + end: 76, + bytes: 12, + description: '"test"', + }); + expect(() => parseWabtOutput(text, 75, ["test"])).toThrow(/ranges/u); + expect(() => + parseWabtOutput(text.replace("size=0x0000000c", "size=0x0000000d"), 76, [ + "test", + ]), + ).toThrow(/ranges/u); + expect(() => + parseWabtOutput( + text.replace("\nSection Details:\n", "unexpected\nSection Details:\n"), + 76, + ["test"], + ), + ).toThrow(/Unrecognized/u); +}); +it("extracts escaped WAT import/export forms while retaining multiline producer text", async () => { + const text = await readFile( + new URL("../../tests/fixtures/wasm/decoded.wat", import.meta.url), + "utf8", + ); + expect(parseWabtWat(text)).toEqual({ + imports: ['(import "env" "foo" (func (;0;) (type 0)))'], + exports: ['(export "memory" (memory 0))', '(export "run" (func 1))'], + customSectionNames: ["test"], + }); + expect( + parseWabtWat( + '(module (import "a.b" "c\\0aline" (func)) (export "fake\\22Export[1]:" (func 0)))', + ), + ).toEqual({ + imports: ['(import "a.b" "c\\0aline" (func))'], + exports: ['(export "fake\\22Export[1]:" (func 0))'], + customSectionNames: [], + }); + expect( + parseWabtWat( + '(module (; nested (; comment ;) ;) ;; comment\n (export "x" (func 0)))', + ).exports, + ).toEqual(['(export "x" (func 0))']); + expect(() => parseWabtWat('(module (export "unfinished"')).toThrow( + /Incomplete/u, + ); + expect(() => parseWabtWat("(module) unexpected")).toThrow(/Unexpected/u); +}); +it("keeps exact local, ambiguous URL, computed and unparseable glue distinct", () => { + const candidates = ["/app/module.wasm", "/other/module.wasm"]; + const result = associateWasmGlue( + 'fetch("./module.wasm"); new URL("https://example.com/module.wasm?x=1", import.meta.url); fetch(`module.wasm`); fetch(name + ".wasm");', + "/app/glue.js", + candidates, + ); + expect(result.parse_status).toBe("complete"); + expect(result.references).toMatchObject([ + { + value: "./module.wasm", + association: "local-path-candidate", + candidate_paths: ["/app/module.wasm"], + }, + { + value: "https://example.com/module.wasm?x=1", + association: "basename-candidates", + candidate_paths: candidates, + }, + { value: "module.wasm", association: "local-path-candidate" }, + { value: ".wasm", association: "unresolved" }, + ]); + expect(associateWasmGlue("function (", "/glue.js", candidates)).toEqual({ + parse_status: "failed", + references: [], + }); +}); diff --git a/src/wasm/WabtOutput.ts b/src/wasm/WabtOutput.ts new file mode 100644 index 000000000..f2406dbdf --- /dev/null +++ b/src/wasm/WabtOutput.ts @@ -0,0 +1,66 @@ +/** Frame WABT's raw custom names using the ordered names from its escaped WAT output. */ +export const parseWabtOutput = ( + text: string, + artifactBytes: number, + customSectionNames: readonly string[], +) => { + const prefix = + /^\nmodule\.wasm:\tfile format wasm 0x1\n\nSections:\n\n/u.exec(text); + if (prefix === null) + throw new Error("Unsupported WABT section header format."); + let offset = prefix[0].length; + let customIndex = 0; + const sections = []; + while (!text.startsWith("\nSection Details:\n", offset)) { + const match = + /^[ \t]+(\w+) start=0x([\da-f]+) end=0x([\da-f]+) \(size=0x([\da-f]+)\) /u.exec( + text.slice(offset), + ); + if (match === null) throw new Error("Unrecognized WABT section row."); + offset += match[0].length; + let description: string; + if (match[1] === "Custom") { + const name = customSectionNames[customIndex++]; + if (name === undefined || !text.startsWith(`"${name}"\n`, offset)) + throw new Error( + "WABT custom-section header does not match decoded WAT.", + ); + description = `"${name}"`; + offset += description.length + 1; + } else { + const end = text.indexOf("\n", offset); + if (end < 0) throw new Error("Incomplete WABT section row."); + description = text.slice(offset, end).trim(); + offset = end + 1; + } + const section = { + kind: match[1] ?? "", + start: Number.parseInt(match[2] ?? "", 16), + end: Number.parseInt(match[3] ?? "", 16), + bytes: Number.parseInt(match[4] ?? "", 16), + description, + }; + if ( + section.end > artifactBytes || + section.end - section.start !== section.bytes || + section.start < 8 || + section.start < (sections.at(-1)?.end ?? 8) + ) + throw new Error("WABT section ranges do not match selected artifact."); + sections.push(section); + } + if (customIndex !== customSectionNames.length) + throw new Error( + "WABT custom-section inventory does not match decoded WAT.", + ); + if ((sections.at(-1)?.end ?? 8) !== artifactBytes) + throw new Error( + "WABT section output does not cover the selected artifact.", + ); + const separator = offset + 1; + return { + sections, + headers: text.slice(0, separator), + details: `module.wasm:\tfile format wasm 0x1\n\n${text.slice(separator)}`, + }; +}; diff --git a/src/wasm/WabtRelease.ts b/src/wasm/WabtRelease.ts new file mode 100644 index 000000000..5920d9523 --- /dev/null +++ b/src/wasm/WabtRelease.ts @@ -0,0 +1,12 @@ +/** Exact upstream release whose producer output is admitted by this adapter. */ +export const WABT_PROVIDER_IDENTITY = { + id: "wabt-artifact", + name: "REA WABT artifact adapter", + version: "wabt@1.0.42", +} as const; +export const WABT_LIMITS = { + inputBytes: 8 * 1024 * 1024, + toolBytes: 64 * 1024 * 1024, + outputBytes: 32 * 1024 * 1024, + timeoutMs: 30_000, +} as const; diff --git a/src/wasm/WabtWat.ts b/src/wasm/WabtWat.ts new file mode 100644 index 000000000..a653d8e58 --- /dev/null +++ b/src/wasm/WabtWat.ts @@ -0,0 +1,124 @@ +/** Decode only a producer custom-section name to frame objdump's raw display text. */ +const customSectionName = (form: string): string => { + const prefix = /^\(@custom\s+"/u.exec(form); + if (prefix === null) throw new Error("Unsupported WABT custom-section name."); + const start = prefix[0].length; + 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 new TextDecoder("utf-8", { fatal: true }).decode( + bytes.subarray(0, length), + ); +}; + +/** Extract exact top-level WABT WAT forms; no binary decoding or import/export name unescaping. */ +export const parseWabtWat = (text: string) => { + if (!/^\(module(?:\s|\))/u.test(text)) + throw new Error("Unsupported WABT WAT module prefix."); + const imports: string[] = []; + const exports: string[] = []; + const customSectionNames: string[] = []; + let depth = 0; + let start = 0; + let quoted = false; + let commentDepth = 0; + let lineComment = false; + let closed = false; + for (let index = 0; index < text.length; index++) { + const char = text[index]; + const next = text[index + 1]; + if (lineComment) { + if (char === "\n") lineComment = false; + continue; + } + if (quoted) { + if (char === "\\") index++; + else if (char === '"') quoted = false; + continue; + } + if (commentDepth > 0) { + if (char === "(" && next === ";") { + commentDepth++; + index++; + } else if (char === ";" && next === ")") { + commentDepth--; + index++; + } + continue; + } + if (char === ";" && next === ";") { + lineComment = true; + index++; + continue; + } + if (char === "(" && next === ";") { + commentDepth++; + index++; + continue; + } + if (char === '"') { + if (depth === 0) + throw new Error("Unexpected string outside WABT WAT module."); + quoted = true; + continue; + } + if (char === "(") { + if (closed) throw new Error("Unexpected second WABT WAT module."); + depth++; + if (depth === 2) start = index; + } else if (char === ")") { + if (depth === 2) { + const form = text.slice(start, index + 1); + 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)) + customSectionNames.push(customSectionName(form)); + } + if (--depth < 0) throw new Error("Unbalanced WABT WAT output."); + if (depth === 0) closed = true; + } else if (depth === 0 && char !== undefined && !/\s/u.test(char)) + throw new Error("Unexpected text outside WABT WAT module."); + } + if (depth !== 0 || quoted || commentDepth !== 0) + throw new Error("Incomplete WABT WAT output."); + return { imports, exports, customSectionNames }; +}; diff --git a/src/wasm/WasmGlueAdapter.ts b/src/wasm/WasmGlueAdapter.ts new file mode 100644 index 000000000..1f8713042 --- /dev/null +++ b/src/wasm/WasmGlueAdapter.ts @@ -0,0 +1,71 @@ +import { basename, dirname, resolve } from "node:path"; +import * as t from "@babel/types"; +import { parseJavaScriptSource } from "../domain/javascript/javascriptSourceParser.js"; +import { traverseJavaScriptAst } from "../domain/javascript/javascriptSemanticTraversal.js"; +/** Associate visible literals with selected candidates without asserting data flow or URL identity. */ +export const associateWasmGlue = ( + source: string, + path: string, + candidates: readonly string[], +) => { + const file = parseJavaScriptSource(source); + const references: { + value: string; + line: number; + column: number; + candidate_paths: string[]; + association: "local-path-candidate" | "basename-candidates" | "unresolved"; + evidence_kind: "static-literal"; + }[] = []; + if (file === null) return { parse_status: "failed" as const, references }; + traverseJavaScriptAst(file, { + enter(node) { + const value = t.isStringLiteral(node) + ? node.value + : t.isTemplateLiteral(node) && node.expressions.length === 0 + ? node.quasis[0]?.value.cooked + : undefined; + if ( + value === undefined || + value === null || + !/\.wasm(?:[?#].*)?$/iu.test(value) || + node.loc == null + ) + return; + const stripped = value.split(/[?#]/u)[0] ?? value; + const local = + !/^[a-z][a-z\d+.-]*:|^\/\//iu.test(value) && + !value.startsWith("/") && + !/[?#]/u.test(value); + const exact = local + ? candidates.filter( + (candidate) => candidate === resolve(dirname(path), value), + ) + : []; + const matches = + exact.length > 0 + ? exact + : candidates.filter( + (candidate) => basename(candidate) === basename(stripped), + ); + references.push({ + value, + line: node.loc.start.line, + column: node.loc.start.column, + candidate_paths: [...matches], + association: + exact.length > 0 + ? "local-path-candidate" + : matches.length > 0 + ? "basename-candidates" + : "unresolved", + evidence_kind: "static-literal", + }); + }, + }); + return { + parse_status: + file.errors.length === 0 ? ("complete" as const) : ("partial" as const), + references, + }; +}; diff --git a/tests/boundary/mcp/wasmArtifactMcp.test.ts b/tests/boundary/mcp/wasmArtifactMcp.test.ts new file mode 100644 index 000000000..62b85b6e1 --- /dev/null +++ b/tests/boundary/mcp/wasmArtifactMcp.test.ts @@ -0,0 +1,84 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect, it, onTestFinished, vi } from "vitest"; +import { WasmArtifactService } from "../../../src/application/wasm/WasmArtifactService.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"; +import { + wasmArtifactFixture, + wasmArtifactExecution, +} from "../../fixtures/wasm/artifact.js"; + +it("advertises exact valid schemas and records inline WASM interface evidence without a binary target", async () => { + const value = wasmArtifactFixture(); + const close = vi.fn(() => Promise.resolve()); + const service = new WasmArtifactService({ + close, + inspect: () => Promise.resolve(ok(wasmArtifactExecution(value))), + }); + const session = createTestBinarySession(() => { + throw new Error("deep provider must not start"); + }); + const server = createServer( + { kind: "session", session }, + { wasmArtifact: service }, + ); + const client = new Client({ name: "wasm-artifact-contract", version: "1" }); + onTestFinished(async () => { + await client.close(); + await server.close(); + await session.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const advertised = (await client.listTools()).tools.find( + (tool) => tool.name === "inspect_wasm_artifact", + ); + if (advertised?.outputSchema === undefined) + throw new Error("WASM 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); + expect(ajv.validate(inputSchema, { path: value.artifact.path })).toBe(true); + expect( + ajv.validate(inputSchema, { + path: value.artifact.path, + + approval: true, + }), + ).toBe(false); + const response = await client.callTool({ + 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, + ); + 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({ + name: "inspect_wasm_artifact", + arguments: { path: "relative.hex" }, + }) + ).isError, + ).toBe(true); + await server.close(); + expect(close).toHaveBeenCalled(); +}); diff --git a/tests/fixtures/wasm/README.md b/tests/fixtures/wasm/README.md new file mode 100644 index 000000000..9743996dc --- /dev/null +++ b/tests/fixtures/wasm/README.md @@ -0,0 +1,13 @@ +# WABT producer fixtures + +`module.wat` is source-owned. WABT 1.0.42 `wat2wasm module.wat -o module.wasm` +produced the binary used for `wasm-objdump -h -x module.wasm` (`objdump.txt`) and +`wasm2wat --enable-all module.wasm` (`decoded.wat`). No binary is checked in. +The module has an imported function, exported memory/function and a `test` +custom section. The custom payload is retained as an `@custom` annotation in decoded WAT. + +Upstream tag commit: `ff0ef7e0009402740c805a9744c09b05be063e48`. +The separate real-tool verifier generates its own fixtures with caller-supplied +WABT and compares exact producer output against the public CLI/MCP surfaces. +`custom-names.wat` covers newlines, quotes, Unicode, backslashes and embedded +section markers in custom names; objdump displays those names without escaping. diff --git a/tests/fixtures/wasm/artifact.ts b/tests/fixtures/wasm/artifact.ts new file mode 100644 index 000000000..9119d612b --- /dev/null +++ b/tests/fixtures/wasm/artifact.ts @@ -0,0 +1,52 @@ +import { createHash } from "node:crypto"; +import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; +/** Source-owned transport seam; upstream binaries are verified in a separate lane. */ +export const wasmArtifactFixture = (path = "/artifacts/module.wasm") => ({ + artifact: { path, sha256: "a".repeat(64), bytes: 8 }, + validation: "valid" as const, + sections: [], + imports: [], + exports: [], + headers: "", + details: "", + wat: { + text: "(module)\n", + sha256: createHash("sha256").update("(module)\n").digest("hex"), + representation: "decoded-wat" as const, + }, + tool_profile: { + source: "https://github.com/WebAssembly/wabt" as const, + release: "1.0.42" as const, + source_commit: "ff0ef7e0009402740c805a9744c09b05be063e48" as const, + commands: ["wasm-validate", "wasm-objdump", "wasm2wat"].map((tool) => ({ + tool, + path: `/tools/${tool}`, + sha256: "b".repeat(64), + version_banner: "1.0.42\n", + arguments: + tool === "wasm-objdump" + ? ["-h", "-x", "module.wasm"] + : ["--enable-all", "module.wasm"], + })), + timeout_ms: 30_000, + output_budget_bytes: 32 * 1024 * 1024, + }, + candidates: [{ path, sha256: "a".repeat(64), bytes: 8 }], + glue: [], + runtime_execution: "not-performed" as const, + network_fetch: "not-performed" as const, + limitations: ["Source-owned transport seam."], +}); +export const wasmArtifactExecution = (value = wasmArtifactFixture()) => + createAnalysisExecution( + value, + { id: "fixture-wabt", name: "Source-owned WABT seam", version: "1" }, + { + subject: { + path: value.artifact.path, + format: "file", + sha256: value.artifact.sha256, + }, + limitations: value.limitations, + }, + ); diff --git a/tests/fixtures/wasm/custom-names.wat b/tests/fixtures/wasm/custom-names.wat new file mode 100644 index 000000000..c3c2dc988 --- /dev/null +++ b/tests/fixtures/wasm/custom-names.wat @@ -0,0 +1,4 @@ +(module + (@custom "test\0aline" "payload") + (@custom "fake\22\0aSection Details:\0a Type start=0x0000000a end=0x0000000b (size=0x00000001) count: 0\0a\22end" "payload") + (@custom "unicode-\c3\a9\\\t" "payload")) diff --git a/tests/fixtures/wasm/decoded.wat b/tests/fixtures/wasm/decoded.wat new file mode 100644 index 000000000..8ae84e5d2 --- /dev/null +++ b/tests/fixtures/wasm/decoded.wat @@ -0,0 +1,9 @@ +(module + (type (;0;) (func)) + (import "env" "foo" (func (;0;) (type 0))) + (func (;1;) (type 0) + call 0) + (memory (;0;) 1) + (export "memory" (memory 0)) + (export "run" (func 1)) + (@custom "test" "payload")) diff --git a/tests/fixtures/wasm/module.wat b/tests/fixtures/wasm/module.wat new file mode 100644 index 000000000..47af20230 --- /dev/null +++ b/tests/fixtures/wasm/module.wat @@ -0,0 +1 @@ +(module (@custom "test" "payload") (import "env" "foo" (func $foo)) (memory (export "memory") 1) (func (export "run") call $foo)) diff --git a/tests/fixtures/wasm/objdump.txt b/tests/fixtures/wasm/objdump.txt new file mode 100644 index 000000000..bd9bd7fd1 --- /dev/null +++ b/tests/fixtures/wasm/objdump.txt @@ -0,0 +1,30 @@ + +module.wasm: file format wasm 0x1 + +Sections: + + Type start=0x0000000a end=0x0000000e (size=0x00000004) count: 1 + Import start=0x00000010 end=0x0000001b (size=0x0000000b) count: 1 + Function start=0x0000001d end=0x0000001f (size=0x00000002) count: 1 + Memory start=0x00000021 end=0x00000024 (size=0x00000003) count: 1 + Export start=0x00000026 end=0x00000036 (size=0x00000010) count: 2 + Code start=0x00000038 end=0x0000003e (size=0x00000006) count: 1 + Custom start=0x00000040 end=0x0000004c (size=0x0000000c) "test" + +Section Details: + +Type[1]: + - type[0] () -> nil +Import[1]: + - func[0] sig=0 <- env.foo +Function[1]: + - func[1] sig=0 +Memory[1]: + - memory[0] pages: initial=1 +Export[2]: + - memory[0] -> "memory" + - func[1] -> "run" +Code[1]: + - func[1] size=4 +Custom: + - name: "test" diff --git a/third_party/wabt/README.md b/third_party/wabt/README.md new file mode 100644 index 000000000..c9875e0e0 --- /dev/null +++ b/third_party/wabt/README.md @@ -0,0 +1,24 @@ +# Bring-your-own WABT + +REA does not bundle, patch, install or download WABT executables. Operators select +an existing tool directory with `REA_WABT_BIN_DIRECTORY`. + +- Upstream: https://github.com/WebAssembly/wabt +- Admitted release: https://github.com/WebAssembly/wabt/releases/tag/1.0.42 +- Source commit: `ff0ef7e0009402740c805a9744c09b05be063e48` +- Upstream license: Apache-2.0, https://github.com/WebAssembly/wabt/blob/1.0.42/LICENSE +- Runtime tools: `wasm-validate`, `wasm-objdump`, `wasm2wat`. +- Verification fixture compiler: `wat2wasm` (not a runtime prerequisite). + +The adapter requires every version banner to report `1.0.42` and records the +resolved executable path, SHA-256, exact banner and arguments for each tool. +Version admission identifies the supported producer profile; it does not certify +publisher authenticity. Operators remain responsible for trusted executable +provenance. No `wasm-interp`, decompiler, replacement binary parser or target +execution is used. + +Real verification on macOS arm64 used the unchanged official +`wabt-1.0.42-macos-arm64.tar.gz` release asset, whose SHA-256 was checked against +GitHub release metadata: +`3f654779b436c628db3ca4323c51538815f5fbc887a6d5f3bb1c18d36281e240`. +This identifies that verification archive, not arbitrary operator-supplied tools. diff --git a/vitest.config.ts b/vitest.config.ts index 2171bf7a2..3e45d5800 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -51,7 +51,7 @@ const TEST_PROJECTS = [ name: "adapters", include: [ "src/*.test.ts", - "src/{apktool,jeb,adb,artifacts,browser,dotnet,evm,firmware,ghidra,hopper,ida,inspector,javascript,native,process,reference,server,windows}/**/*.test.ts", + "src/{apktool,jeb,adb,artifacts,browser,dotnet,evm,firmware,ghidra,hopper,ida,inspector,javascript,native,process,reference,server,wasm,windows}/**/*.test.ts", ], pool: "forks" as const, maxWorkers: MAX_TEST_WORKERS,