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 <[email protected]>
This commit is contained in:
Thang Luu
2026-10-11 19:49:00 +08:00
committed by GitHub
co-authored by morluto
parent e4407dbed5
commit ffaf2c12f1
46 changed files with 2309 additions and 7 deletions
@@ -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
+1
View File
@@ -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/) |
+22
View File
@@ -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.
+71
View File
@@ -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.
+3
View File
@@ -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",
+5
View File
@@ -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,
+2
View File
@@ -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",
+1
View File
@@ -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",
+57
View File
@@ -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();
}
+313
View File
@@ -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: "[email protected]",
entrypoint,
verifier: await completeVerifierRun(run),
},
null,
2,
),
);
+9
View File
@@ -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")
+17
View File
@@ -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<void>;
inspect(
input: InspectWasmArtifactInput,
options?: ExecutionOptions,
): Promise<Result<AnalysisExecution, AnalysisError>>;
}
@@ -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 },
});
});
+100
View File
@@ -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<void> {
return this.provider.close();
}
/** Preserve exact selected artifact identity and validated upstream observations. */
async inspect(
rawInput: unknown,
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
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,
}),
);
}
}
+1 -1
View File
@@ -301,7 +301,7 @@ export class MachOSliceArtifactReader implements ArtifactReader {
}
async #releaseResources(): Promise<void> {
await Promise.allSettled([...this.#pendingCommands]);
await Promise.allSettled(this.#pendingCommands);
let cleanup: ArtifactReaderFailure["cleanup"];
try {
const failures = (await this.runner.retryCleanup?.()) ?? [];
+2
View File
@@ -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);
+60
View File
@@ -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<NodeJS.ProcessEnv>,
): 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();
}
}),
});
};
+1
View File
@@ -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",
+7
View File
@@ -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<NodeJS.ProcessEnv> = process.env,
): WasmArtifactService =>
new WasmArtifactService(new WabtArtifactProvider(environment));
+2
View File
@@ -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,
+5
View File
@@ -41,6 +41,11 @@ const sessionEvidence = effects({ mutatesSession: true });
/** Explicit effect audit for every public tool. */
export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
inspect_wasm_artifact: effects({
mutatesSession: true,
writesFilesystem: true,
launchesProcess: true,
}),
inspect_evm_interface: effects({
mutatesSession: true,
writesFilesystem: true,
+29
View File
@@ -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[];
+1 -1
View File
@@ -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 },
]),
),
+126
View File
@@ -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
>;
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -261,7 +261,7 @@ class NativeMacOSClient implements AnalysisClient {
async close(): Promise<Result<null, AnalysisError>> {
this.#closed = true;
await Promise.allSettled([...this.#pendingCommands]);
await Promise.allSettled(this.#pendingCommands);
const uiCleanupFailures = await this.#serializeNativeUi(() =>
this.#retryNativeUiCleanup(),
);
+25 -1
View File
@@ -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<NodeJS.ProcessEnv>;
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<void> => {
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),
+32
View File
@@ -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),
);
};
+3
View File
@@ -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",
+194
View File
@@ -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<Record<string, string>> = {
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();
}
});
+439
View File
@@ -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<Parameters<typeof runOwnedCommand>[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<PrivateRuntimeRoot, "path" | "close">
>();
constructor(
readonly environment: Readonly<NodeJS.ProcessEnv> = process.env,
readonly launcher?: Launcher,
readonly createRuntime: () => Promise<
Pick<PrivateRuntimeRoot, "path" | "close">
> = () => PrivateRuntimeRoot.create({ prefix: "rea-wabt-" }),
) {}
/** Retry retained cleanup without discarding roots that still cannot be removed. */
async close(): Promise<void> {
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<Result<AnalysisExecution, AnalysisError>> {
let root: Pick<PrivateRuntimeRoot, "path" | "close"> | undefined;
let phase: "configuration" | "artifact" | "producer" = "configuration";
let result: Result<AnalysisExecution, AnalysisError>;
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;
}
}
+111
View File
@@ -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 });
};
+92
View File
@@ -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: [],
});
});
+66
View File
@@ -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)}`,
};
};
+12
View File
@@ -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: "[email protected]",
} as const;
export const WABT_LIMITS = {
inputBytes: 8 * 1024 * 1024,
toolBytes: 64 * 1024 * 1024,
outputBytes: 32 * 1024 * 1024,
timeoutMs: 30_000,
} as const;
+124
View File
@@ -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 };
};
+71
View File
@@ -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,
};
};
@@ -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<string, unknown> = advertised.inputSchema;
const outputSchema: Record<string, unknown> = 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();
});
+13
View File
@@ -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.
+52
View File
@@ -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,
},
);
+4
View File
@@ -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"))
+9
View File
@@ -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"))
+1
View File
@@ -0,0 +1 @@
(module (@custom "test" "payload") (import "env" "foo" (func $foo)) (memory (export "memory") 1) (func (export "run") call $foo))
+30
View File
@@ -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> <- env.foo
Function[1]:
- func[1] sig=0 <run>
Memory[1]:
- memory[0] pages: initial=1
Export[2]:
- memory[0] -> "memory"
- func[1] <run> -> "run"
Code[1]:
- func[1] size=4 <run>
Custom:
- name: "test"
+24
View File
@@ -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.
+1 -1
View File
@@ -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,