mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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/) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
@@ -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")
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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?.()) ?? [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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));
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
@@ -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 },
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
@@ -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)}`,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
Vendored
+13
@@ -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.
|
||||
Vendored
+52
@@ -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,
|
||||
},
|
||||
);
|
||||
Vendored
+4
@@ -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"))
|
||||
Vendored
+9
@@ -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"))
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
(module (@custom "test" "payload") (import "env" "foo" (func $foo)) (memory (export "memory") 1) (func (export "run") call $foo))
|
||||
Vendored
+30
@@ -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"
|
||||
Vendored
+24
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user