Inspect and Diff WebAssembly Modules with REA 6.7 and WABT
On this page
Short answer: REA, the open-source project by morluto, has had two WebAssembly commands since 6.5.0, and we tested them on 6.7.0. rea inspect-wasm-artifact runs WABT 1.0.42 on a local .wasm file and returns the sections, the typed import and export list and the full WAT as one Evidence record. rea compare-wasm-interfaces takes two of those records and tells you which imports and exports were added, removed or changed. REA does not execute the module and does not install WABT for you. You point it at a WABT 1.0.42 directory, and every other version is refused.
If you only want to read a module, the WASM to WAT tool and its walkthrough, WASM to WAT: Inspect a WebAssembly Module in Your Browser, are quicker, and that article explains how to read WAT. This guide covers what REA adds: a record whose hashes you can check later, an interface diff between two builds, and an MCP route an agent can use without pulling 15 MB of WAT into its context.
Tested on: macOS 27.0.1 (arm64), Node.js 22.22.3, rea-agents 6.7.0 from npm, WABT 1.0.42 (upstream macOS arm64 release), binaryen version_133, rustc 1.95.0, AssemblyScript 0.28.20 and Apple clang 21.0.0. We built every module below ourselves, except sql.js 1.13.0 (MIT), which serves as a larger real-world sample. Only analyze modules you built, open-source modules, or modules you are authorized to review, and read REA’s DISCLAIMER.md first.
Install REA and WABT 1.0.42
npm install [email protected]
curl -LO https://github.com/WebAssembly/wabt/releases/download/1.0.42/wabt-1.0.42-macos-arm64.tar.gz
shasum -a 256 wabt-1.0.42-macos-arm64.tar.gz
# 3f654779b436c628db3ca4323c51538815f5fbc887a6d5f3bb1c18d36281e240
tar xzf wabt-1.0.42-macos-arm64.tar.gz
export REA_WABT_BIN_DIRECTORY="$PWD/wabt-1.0.42/bin"
That checksum matches the one recorded in the package’s third_party/wabt/README.md, which says REA “does not bundle, patch, install or download WABT executables”. Homebrew’s wabt was also at 1.0.42 when we checked. The npm package wabt is not: we pointed REA at [email protected] from npm and got
code: capability_unavailable
message: Unsupported wasm-validate version banner; expected 1.0.42. Supply WABT 1.0.42 via absolute REA_WABT_BIN_DIRECTORY; REA does not install tools.
A relative path such as ../wabt-1.0.42/bin fails with REA_WABT_BIN_DIRECTORY must be an absolute tool directory. Neither rea doctor nor rea capabilities mentions WABT in 6.7.0, so the first inspect-wasm-artifact call is your setup check.
One gap in the release tarball: it ships wasm2wat, wasm-objdump, wasm-validate and eight other tools, but no wasm-decompile. Where we use wasm-decompile below, it comes from the npm [email protected] package.
A module to inspect
The first sample is a Rust cdylib with one host import and three exports. This is the core of src/lib.rs; alloc and dealloc are the usual Vec-based helpers:
extern "C" {
fn report(bytes: u32); // provided by the host
}
#[no_mangle]
pub unsafe extern "C" fn crc32(ptr: *const u8, len: usize) -> u32 {
let data = core::slice::from_raw_parts(ptr, len);
let t = table(); // 256-entry CRC table, built on first use
let mut c = 0xFFFF_FFFFu32;
for &b in data {
c = t[((c ^ b as u32) & 0xFF) as usize] ^ (c >> 8);
}
report(len as u32);
!c
}
cargo build --release --target wasm32-unknown-unknown # opt-level = "s"
cp target/wasm32-unknown-unknown/release/crcmod.wasm v1/
Because the crate uses std (for Vec), the allocator and panic machinery come along: the result is 17,633 bytes with 55 defined functions, against three in our source.
First inspection
npx rea inspect-wasm-artifact v1/crcmod.wasm \
--filter-output normalized_result.validation,normalized_result.sections
normalized_result:
validation: valid
sections[13]{kind,start,end,bytes,description}:
Type,10,72,62,"count: 10"
Import,74,88,14,"count: 1"
Function,90,146,56,"count: 55"
...
Code,277,12641,12364,"count: 55"
Data,12644,13086,442,"count: 1"
Custom,13089,17403,4314,"\"name\""
Custom,17405,17482,77,"\"producers\""
Custom,17485,17633,148,"\"target_features\""
The first run took 3.3 s, and later runs about 1.1 to 1.3 s. Under the hood REA copies the file into a private directory, hashes each WABT executable, checks that every --version banner reads exactly 1.0.42, then runs wasm-validate --enable-all, wasm-objdump -h -x and wasm2wat --enable-all. All three command lines and binary hashes are stored in tool_profile.
--filter-output matters here. The full record for this 17 KB module is 197,087 bytes of JSON, and --token-count puts it at 46,511 tokens. Most of that is the WAT. The interface entries are the useful part:
- direction: import
module: env
name: report
signature: { kind: func, params[1]: i32, results: [] }
wat_form: "(import \"env\" \"report\" (func $report (type 3)))"
- direction: export
name: crc32
signature: { kind: func, params[2]: i32,i32, results[1]: i32 }
wat_form: "(export \"crc32\" (func $crc32))"
(trimmed: the output also has memory with minimum: "17" pages, alloc, dealloc, __data_end and __heap_base.)
We checked REA’s numbers against WABT directly. The section table matches wasm-objdump -h byte for byte, which is expected because it is parsed from that output. The WAT inside the record has SHA-256 622ac13b…8339, and so does the output of running wasm2wat --enable-all ourselves; the two files are identical. So REA doesn’t decode anything new. It records exactly what WABT said, which is the point when you want to cite it later.
Reading crc32, and a wasm-decompile trap
The table lookup in the WAT is easy to spot:
i32.const 0
i32.load offset=1049012 ;; already initialised?
...
i32.const -306674912 ;; 0xEDB88320, the CRC-32 polynomial
i32.xor
wasm-decompile shows the same loads as 0[262253]:int and (d << 2)[262252]:int. Those numbers aren’t addresses: 262,253 × 4 = 1,049,012. The decompiler divides the offset by the access size and prints it as an array index. When you compare its output against the WAT or a crash offset, multiply it back.
Diff two builds
For v2 we changed the ABI the way real projects do by accident: crc32 gained a seed: u32 parameter, dealloc was renamed to free_buf, and an abi_version() export was added. The new build is 17,671 bytes.
compare-wasm-interfaces takes one argument, inline JSON or a file, containing both complete Evidence records and a mandatory scope:
npx rea inspect-wasm-artifact v1/crcmod.wasm --format json > v1.json
npx rea inspect-wasm-artifact v2/crcmod.wasm --format json > v2.json
node -e 'const fs=require("fs");fs.writeFileSync("cmp.json",JSON.stringify({
left: JSON.parse(fs.readFileSync("v1.json")),
right: JSON.parse(fs.readFileSync("v2.json")), scope: "both"}))'
npx rea compare-wasm-interfaces cmp.json --format json
The comparison took 0.4 s. Trimmed to one line per entry:
summary: added 2, removed 1, changed 1, unchanged 5, unknown 0 coverage: complete-within-inputs
export crc32 changed (i32, i32) -> i32 => (i32, i32, i32) -> i32
export dealloc removed (i32, i32) -> ()
export free_buf added (i32, i32) -> ()
export abi_version added () -> i32
import env.report unchanged (i32) -> ()
There are two things to keep in mind when you read it. A rename appears as one removal plus one addition, because there is no rename detection. And seed: u32 shows up as i32, because WebAssembly has no unsigned integer types, so a signedness change in the source would not appear as a change at all. The tool’s own limitation says so too: “Exact declared interface equality is not runtime/ABI compatibility”.
The input has to be the exact Evidence. When we changed artifact.bytes in one record, or deleted its wat field to make the file smaller, the command exited with 1 and Evidence semantic identifier does not match its record. The evidence_id is derived from the content, so the CLI and MCP gave our v1 build the same ID (ev_30646050…). Saving the default toon output and passing that in fails with expected: object. Always save --format json, without --filter-output.
Did the optimizer change the interface?
wasm-opt -Oz --strip-debug --strip-producers (binaryen 133) shrank v2 from 17,671 to 11,553 bytes and from 56 to 33 functions in 0.04 s. Comparing the two records: unchanged: 8, nothing else. The wat_form strings did change, from (func $crc32) to (func 3) once the name section was gone, but the comparison matches on name, kind and signature, which is what a release check needs.
Glue association
--glue_paths lists JavaScript files to scan for string literals that end in .wasm. --candidate_paths lists local modules those literals might refer to. The AssemblyScript compiler gave us a real loader:
npx asc assembly/index.ts --outFile build/release.wasm --bindings esm -O3 --noAssert
npx rea inspect-wasm-artifact build/release.wasm --glue_paths build/release.js --format json
REA found new URL("release.wasm", import.meta.url) at line 67, column 11 of release.js and marked it local-path-candidate. The 9,285-byte module still imports env.abort even with --noAssert, and its string parameters appear as i32 pointers.
To see the other association states we wrote a four-line loader. With v1 as the target and v2 plus the AssemblyScript module as candidates:
3 "/static/release.wasm" basename-candidates [as/build/release.wasm]
4 "https://cdn.example.com/build/crcmod.wasm?v=3" basename-candidates [v1/crcmod.wasm, v2/crcmod.wasm]
5 "./v2/crcmod.wasm" local-path-candidate [v2/crcmod.wasm]
6 "crcmod.wasm" (from base + "crcmod.wasm") basename-candidates [v1/crcmod.wasm, v2/crcmod.wasm]
Line 6 shows how literal the scan is: the source said base + "crcmod.wasm" with base = "./v1/", but REA doesn’t evaluate expressions, so both builds match by basename. Use it as a pointer for where to look, not as proof of which file loads.
Pitfall we hit: our first try was --candidate_paths v2/crcmod.wasm as/build/release.wasm. It ran without error, but parameters.candidate_paths held only the first path, and line 3 came back unresolved. The second value was dropped silently; an extra positional argument is ignored the same way. Repeat the flag for each value (--candidate_paths a.wasm --candidate_paths b.wasm). A comma-separated list is read as one path and fails with ENOENT.
A C module, and lost signedness
Apple’s clang 21 includes the wasm32 backend but no WebAssembly linker. clang --target=wasm32 -nostdlib ... compiled our file and then stopped with clang: error: unable to execute command: posix_spawn failed: No such file or directory. rustup already ships one, so we compiled with -c and linked with it:
clang --target=wasm32 -O2 -nostdlib -c b64.c -o b64.o
~/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/aarch64-apple-darwin/bin/gcc-ld/wasm-ld \
--no-entry --allow-undefined -o b64.wasm b64.o # LLD 22.1.2
The 844-byte module imports host.trace and exports input_ptr, output_ptr and b64_encode(i32) -> i32. Its source rejects len < 0 || len > 3072. Clang merged both tests into one unsigned compare, and the WAT shows it as i32.const 3072 / i32.gt_u. wasm-decompile prints export function b64_encode(a:int):int and then if (a > 3072) goto B_a;, which reads as if a negative length would get through. It wouldn’t. When the decompiler’s version and the WAT disagree on a bounds check, go with the WAT.
A larger module: sql.js
sql-wasm.wasm from the sql.js 1.13.0 npm package is 659,806 bytes: 1,883 functions, 38 imports, 53 exports. Emscripten minified the interface, so every import is module "a" with names like "a", "b", and the exports are "M", "N", "O" and so on. wasm-opt --metrics from binaryen agrees on all three counts. Our first attempt failed, though, with memory.fill operations require bulk memory [--enable-bulk-memory-opt], because this module has no target_features section. Pass -all to wasm-opt, the same way REA passes --enable-all to WABT.
REA took 1.6 s. The CLI record is 15.6 MB, the WAT alone is 14,817,970 characters (288,167 lines), and --token-count reports 1,997,058 tokens. The glue scan found "sql-wasm.wasm" twice on line 157 of the minified sql-wasm.js, both resolved to the local file.
The same through MCP
The MCP server (rea --mcp) exposes inspect_wasm_artifact and compare_wasm_interfaces, with paging through inspect_analysis_view. On sql.js a plain inspect_wasm_artifact call failed:
code: resource_constraint
message: The operation completed, but its complete MCP response exceeds the stdio response budget. The analysis result was not truncated. ...
The intended order is a summary first, then pages:
{"name": "inspect_wasm_artifact",
"arguments": {"path": "/abs/sql-wasm.wasm", "detail": "summary"}}
{"name": "inspect_analysis_view",
"arguments": {"source": {"kind": "retained-evidence", "evidence_id": "<normalized_result.parent_evidence_id>"},
"view": {"kind": "wasm", "facet": "wat", "offset": 0, "limit": 20000}}}
The summary came back in 1.1 s at about 11 KB: validation, counts, WAT SHA-256 and code_units: 14817970, but no WAT. A 20,000-unit WAT page took 17 ms. At that size the whole WAT is 741 pages, so in practice an agent reads the imports and exports facets and searches the WAT locally with WABT. compare_wasm_interfaces accepts {"kind": "retained-evidence", "evidence_id": ...} for both sides within the same session. On our two Rust builds with scope: "exports" it returned the same changes as the CLI.
Limits we confirmed
- Files over 8 MiB fail before WABT runs (
Selected artifact exceeds 8388608 bytes). Symlinks are refused (Expected a regular file without a symlink). The file extension doesn’t matter: a copy namedcrcmod.binvalidated. - Invalid bytes produce a partial record with WABT’s own message, for example
0000115: error: invalid section size: extends past endfor a truncated file. It does not produce a guess. - Each WABT call has a 30 s deadline and a 32 MiB output budget. sql.js used about 15 MB of it.
- Only core modules are compared; components are out of scope. Nothing is executed or fetched (
runtime_execution: not-performed). - We tested only macOS arm64. The package notes say WABT was verified there too; other hosts need checking.
Next steps
- Read WAT by hand, or check a module without installing anything: WASM to WAT tool and its walkthrough.
- Read the JavaScript loader around the module: Recover JavaScript Source from Bundles.
- Find which
.wasma page you’re authorized to test loads: Trace Website API Calls. - Keep the two Evidence records with your release and compare them over time: REA Evidence, Snapshots and Version Comparison.
- Get the MCP server running in your agent: Getting Started with REA MCP.