chore: sync current main into the CI baseline repair

Validate the formatting and reconstruction fixture repairs against the
latest merged feature and correctness changes.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
N0zoM1z0
2026-10-13 15:50:55 +00:00
co-authored by Kaoru0822
63 changed files with 3141 additions and 321 deletions
@@ -175,7 +175,9 @@ additional evidence only when the provider accepts and matches them.
REA has a built-in read-only snapshot parser for x64 PE targets with .NET 8 RTR
9.1 metadata. An optional headless Ghidra adapter adds database annotations and
broader metadata recovery on Linux; it requires an explicitly configured
NativeAOT JAR. These are separate routes. See the
NativeAOT JAR. These are separate routes. The built-in parser also runs on
Windows Ghidra P0; read frozen literals from its report, because
`search_strings` does not include them. See the
[NativeAOT guide](https://github.com/morluto/rea/blob/main/docs/ghidra-nativeaot.md)
for setup, supported layouts, and host restrictions. Preserve binary identity
and distinguish derived metadata from Ghidra database observations. Recovered
@@ -247,3 +249,9 @@ resolution is explicit; missing references and size mismatch remain visible.
Other payloads are opaque, and this operation does not decode images or simulate
Windows resource loading. Consult the budgets and supported tree profile in
[offline binary diagnostics](https://github.com/morluto/rea/blob/main/docs/binary-diagnostics.md).
For exact core-module import/export differences, use `compare_wasm_interfaces`
with two complete inspection Evidence records (or current-session retained
references) and explicit scope. Preserve unknown types/duplicate names; static
interface equality is not runtime compatibility. CLI accepts complete inline
Evidence in `rea compare-wasm-interfaces input.json --json`.
+31 -10
View File
@@ -11,6 +11,12 @@ import sys
PROFILE = "pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2"
# Linux ET_CORE machine identifiers whose PRSTATUS layout is interpreted with
# unchanged pwntools 4.15.0 ctypes. Both share pr_cursig@12, pr_pid@32 and
# pr_reg@112; only the register window differs: amd64 is 27 x 8 bytes for a
# 336-byte descriptor, AArch64 is 34 x 8 bytes for a 392-byte descriptor.
CORE_MACHINES = (62, 183)
class CoreFailure(Exception):
def __init__(self, reason, message):
@@ -51,12 +57,27 @@ def inspect_core(path, cache):
# Other declared OSABIs must never select Linux note ctypes.
if content[7] not in (0, 3) or content[8] != 0:
raise CoreFailure("unsupported-target", "Initial recorded-core profile requires System V or GNU/Linux OSABI with ABI version zero; selected OSABI=" + str(content[7]) + ", ABI version=" + str(content[8]) + ".")
if int.from_bytes(content[16:18], "little") != 4 or int.from_bytes(content[18:20], "little") != 62:
raise CoreFailure("unsupported-target", "Initial recorded-core profile requires x86-64 ET_CORE.")
machine = int.from_bytes(content[18:20], "little")
if int.from_bytes(content[16:18], "little") != 4 or machine not in CORE_MACHINES:
raise CoreFailure("unsupported-target", "Initial recorded-core profile requires ET_CORE for x86-64 or AArch64; selected ET_TYPE=" + str(int.from_bytes(content[16:18], "little")) + ", e_machine=" + str(machine) + ".")
from elftools.common.exceptions import ELFError
from elftools.common.utils import struct_parse
from elftools.elf.elffile import ELFFile
from pwnlib.elf.datatypes import elf_prstatus_amd64, elf_siginfo_64, user_regs_struct_amd64
from pwnlib.elf.datatypes import (
elf_prstatus_aarch64,
elf_prstatus_amd64,
elf_siginfo_64,
user_regs_struct_aarch64,
user_regs_struct_amd64,
)
if machine == 62:
profile_label = "x86-64"
architecture = "x86_64-little-endian"
prstatus_type, registers_type = elf_prstatus_amd64, user_regs_struct_amd64
else:
profile_label = "AArch64"
architecture = "aarch64-little-endian"
prstatus_type, registers_type = elf_prstatus_aarch64, user_regs_struct_aarch64
try:
image = ELFFile(io.BytesIO(content))
header = image.header
@@ -107,17 +128,17 @@ def inspect_core(path, cache):
if owner != b"CORE\0":
continue
if note.n_type == "NT_PRSTATUS":
if len(descriptor) < ctypes.sizeof(elf_prstatus_amd64):
raise CoreFailure("format", "Linux amd64 NT_PRSTATUS descriptor is truncated.")
status = elf_prstatus_amd64.from_buffer_copy(descriptor)
if len(descriptor) < ctypes.sizeof(prstatus_type):
raise CoreFailure("format", "Linux " + profile_label + " NT_PRSTATUS descriptor is truncated.")
status = prstatus_type.from_buffer_copy(descriptor)
registers = []
for name, kind in user_regs_struct_amd64._fields_:
relative = elf_prstatus_amd64.pr_reg.offset + getattr(user_regs_struct_amd64, name).offset
for name, kind in registers_type._fields_:
relative = prstatus_type.pr_reg.offset + getattr(registers_type, name).offset
registers.append({"name": name, "value": hex(getattr(status.pr_reg, name)), "location": location(descriptor_start + relative, ctypes.sizeof(kind), end)})
threads.append({"note_index": note_index, "historical_pid": ctypes.c_int32(status.pr_pid).value, "recorded_current_signal": status.pr_cursig, "registers": registers})
elif note.n_type == "NT_SIGINFO":
if len(descriptor) < ctypes.sizeof(elf_siginfo_64):
raise CoreFailure("format", "Linux amd64 NT_SIGINFO descriptor is truncated.")
raise CoreFailure("format", "Linux " + profile_label + " NT_SIGINFO descriptor is truncated.")
signal = elf_siginfo_64.from_buffer_copy(descriptor)
number = ctypes.c_int32(signal.si_signo).value
code = ctypes.c_int32(signal.si_code).value
@@ -125,7 +146,7 @@ def inspect_core(path, cache):
signals.append({"note_index": note_index, "number": number, "code": code, "errno": ctypes.c_int32(signal.si_errno).value,
"fault_address": hex(signal.sigfault_addr) if known else None,
"fault_address_meaning": "recorded-sigsegv-address" if known else "unknown", "thread_association": "unknown"})
return {"format": "elf-core", "architecture": "x86_64-little-endian", "target_execution": "not-performed", "live_process_identity": "unknown",
return {"format": "elf-core", "architecture": architecture, "target_execution": "not-performed", "live_process_identity": "unknown",
"segments": segments, "notes": notes, "note_padding": padding, "threads": threads, "signals": signals,
"note_interpretation_completeness": "unknown",
"limitations": ["The initial Linux note profile accepts System V/unspecified or GNU/Linux OSABI with ABI version zero; an unspecified OSABI alone does not prove operating-system origin.",
+25 -7
View File
@@ -17,10 +17,14 @@ submodule and is a separate, more capable workflow.
The parser currently handles x64 PE32+ and RTR 9.1, including bounded
DEHYDRATED_DATA processing, MethodTable relationships, virtual slots, and
frozen strings. Other RTR versions and PE architectures are reported as
unsupported or partial. The matching .NET 8.0.22 fixture is source-built and
the parser's Windows-host Ghidra workflow has not yet had a real Windows-host
run. This does not establish Windows Ghidra import or decompiler behavior.
Windows Ghidra P0 has no database mutation authority. A PE/CLI or ReadyToRun
unsupported or partial. The matching .NET 8.0.22 fixture is source-built.
On a Windows host, Windows Ghidra P0 runs the parser without a JAR. Metadata,
MethodTable relationships, virtual slots and frozen strings come from the
parser report, and `analyze_function` decompiles slot targets. Tools that read
the Ghidra database do not see rehydrated bytes (see below), because only the
optional adapter writes them into the database and Windows Ghidra P0 has no
database mutation authority. A PE/CLI or ReadyToRun
assembly should use `inspect_managed_artifact`; it is not NativeAOT.
## Build and configure
@@ -57,7 +61,9 @@ returns `observations.metadata_recovery` inline from the captured source snapsho
including the RTR header, MethodTable addresses, frozen strings, derived overlay
identity, and examined/recovered counts. Its enclosing `unsupported` load-image
status means independent PE load-image verification was not performed; it does
not mean the metadata parser failed or the executable failed to load.
not mean the metadata parser failed or the executable failed to load. Without
the optional adapter, each recovered type's `type` is `null`, because no Ghidra
DataType exists; select it by `address`.
Pass a returned MethodTable address to `inspect_native_data_type`. A direct PE
query also derives the snapshot metadata from the authenticated Ghidra session
@@ -67,8 +73,12 @@ The database result remains `unavailable` when Ghidra has no DataType at that
address; its `metadata_recovery.source` is `read-only-derived-overlay` and
carries related type, interface, slot, and target observations. Generated
address identities do not claim original managed names. The parser does not
merge frozen literals into Ghidra's database string inventory. Follow slot
targets with `analyze_function` and ordinary cross-references.
merge frozen literals into Ghidra's database string inventory: read them from
`metadata_recovery.frozen_strings`, not `search_strings`. Slot targets come
from the parser report; on Windows P0 without the adapter, a `read_bytes` of
a slot in the rehydrated region returns no bytes. The Linux adapter route
verifies a complete slot read and its target. Follow slot targets with `analyze_function` and ordinary
cross-references.
The built-in CLI path uses a MethodTable address returned by the parser or
another observation:
@@ -129,3 +139,11 @@ compiler/linker symbols and raw directory bytes provide the oracle. Stripped
fixtures exercise heuristic detection. Tests do not execute the resulting
NativeAOT binaries. Fixture generation and adapter compilation are optional and
never prerequisites of ordinary native analysis or setup.
The real MCP lane runs only on a Linux x64 host with the adapter. The
Windows-host behavior above was checked manually over MCP on Windows 11 x64
(non-elevated, Ghidra 12.1.4, JDK 21, no JAR) with the source-built win-x64
symbols fixture. The format, RTR version and frozen strings matched the fixture
oracle; the derived MethodTable, its base type, its interface and a virtual
slot reaching the overridden method matched the linker map; and pseudocode was
readable. No CI lane repeats this check.
+3 -2
View File
@@ -233,8 +233,9 @@ these routes, their layout/host restrictions, derived-memory provenance, and
verification coverage.
REA's experimental Windows Ghidra boundary admits native x86 and x86-64 PE
applications and DLLs for static analysis. This does not establish Windows
NativeAOT metadata-recovery coverage.
applications and DLLs for static analysis. For x64 PE NativeAOT, the read-only
parser also runs there; the [NativeAOT guide](ghidra-nativeaot.md) lists what a
Windows host covers.
For NativeAOT analysis, preserve the selected native artifact's path and digest
and report recovered type data with its returned provenance. Linking those types
+8 -5
View File
@@ -604,8 +604,12 @@ failures for such entries name this cause and the operation to rerun with
Advertised input and output schemas contain no reachable recursive references.
Schemas share repeated definitions through schema-local references while
preserving complete nested fields and validation rules. Input properties retain
their descriptions and literal examples.
preserving complete nested fields and validation rules. Input schemas whose
shared definitions would exhaust a client's declaration-rendering reference
budget (Pi 1.1.0 code-mode shares 32 reference expansions across an input and
renders every later reference as `unknown`) are re-projected without shared
definitions, so every advertised field renders with complete structure.
Input properties retain their descriptions and literal examples.
When a host forwards input schemas to the model, their examples consume context.
REA's advertised examples therefore never copy complete producer Evidence.
@@ -652,7 +656,7 @@ JSON Schema:
objects or arrays.
- The few schemas whose validation structure alone exceeds the budget
(`compare_web_captures`, `build_reconstruction_obligation_ledger`,
`evaluate_reconstruction_coverage`, `project_managed_application_graph`,
`evaluate_reconstruction_coverage`,
`compare_application_versions`, `capture_browser_scenario`) are advertised
in a reduced form: property names, reference-resolved types (including union
alternatives), first-level guidance, and the root's required-field constraints,
@@ -662,8 +666,7 @@ JSON Schema:
instead of silent acceptance.
Boundary tests pin every advertised compact schema to the budget, keep them
valid JSON Schema that still accepts every canonical example, and hold the
set of six reduced presentations as contracts evolve. Client verification
valid JSON Schema that still accepts every canonical example. Client verification
also checks provider-facing sizes and canonical examples after conversion by
the actual Kimi Code CLI; local captures do not establish Moonshot acceptance.
+8 -4
View File
@@ -1,7 +1,10 @@
# Recorded crash evidence
`inspect_recorded_crash` / `rea inspect-recorded-crash` inspect a caller-selected
Linux ELF64 x86-64 little-endian core independently of an active binary target.
Linux ELF64 little-endian core independently of an active binary target. Two
architecture profiles are interpreted, x86-64 and AArch64; each binds registers
to its own `NT_PRSTATUS` layout, and any other `e_machine` is refused as
`unsupported_target` before note interpretation.
```sh
export REA_PWNTOOLS_PYTHON=/absolute/existing/venv/bin/python
@@ -10,8 +13,8 @@ rea inspect-recorded-crash ./crash.core --json
The selected Python needs unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2.
REA does not install these dependencies. ELFFile/its original ELF structures and
pwntools' original Linux amd64 ctypes provide parsing and ABI layout. The adapter
adds bounds, representation preservation and lifecycle ownership.
pwntools' original Linux amd64 and aarch64 ctypes provide parsing and ABI layout.
The adapter adds bounds, representation preservation and lifecycle ownership.
The initial Linux note profile accepts System V/unspecified and GNU/Linux OSABI
with ABI version zero. Other declared ABIs return `unsupported_target` before
note interpretation; an unspecified OSABI alone does not prove OS origin.
@@ -27,7 +30,8 @@ note interpretation; an unspecified OSABI alone does not prove OS origin.
- Every exact Linux CORE PRSTATUS note as a separate recorded thread, with
historical PID, signal and lossless uint64 registers bound to source bytes.
Each supported note has exactly one interpretation; each thread contains the
complete 27-register ABI set, with names bound to their original ABI offsets.
complete ABI register set for its architecture (27 registers for amd64,
34 for AArch64), with names bound to their original ABI offsets.
Physical note ranges cannot overlap or leave segment gaps. ELF and note headers
are independently bound to the snapshot, including extended program counts.
- Signed SIGINFO number/code/errno. Initially only SIGSEGV codes 1/2 establish the
@@ -0,0 +1,10 @@
# Plan
1. Check issue/timeline and complete open/closed PR searches; review #2406.
2. Extend the existing core-module WABT inspection in an isolated worktree.
3. Extend producer-owned inspection with optional typed interface facts;
preserve unknown unsupported signatures and exact WAT forms.
4. Add shared Evidence-only comparator and strict input/output contracts.
5. Register additive CLI/MCP workflow and documentation.
6. Validate original-module workflows, malformed/unknown/cancellation cases;
run checks, independent review, repair findings and rerun affected checks.
@@ -0,0 +1,61 @@
# Core WebAssembly interface comparison (#2356)
## Outcome and scope
Compare two exact authenticated `inspect_wasm_artifact` Evidence inputs, inline
or retained session references, under required `scope: imports | exports | both`.
No module acquisition or execution occurs during comparison. This is a derived
workflow within existing WABT inspection authority, not a new provider.
Producer-owned typed `interface` facts are additive and optional in historical
inspection Evidence. WABT's validated canonical WAT owns decoding; exact original
forms and names remain available. Comparison never reparses retained display text.
Function/tag signatures resolve type uses; memory page size defaults to 65536 bytes and explicit custom page sizes are retained; table/memory limits and global types
are compared exactly. Unsupported type/proposal forms remain unknown rather than
being compared by raw text or inferred compatible. Names are exact decoded UTF-8,
without normalization, including empty names, escapes, punctuation and BOM.
## Comparison contract
Return both Evidence IDs and original artifact path/digest/length. Group imports
by exact module/name and exports by exact name, independently of kind, so a kind
change is reported as changed. Unique admitted signatures yield added, removed,
changed or unchanged. Duplicate keys retain every original occurrence and yield
unknown; no Cartesian pairing or deduplication. Missing historical facets and
incomplete inventories prevent absence claims. Unknown signatures do not erase
independently observed entries. Ordering is deterministic by direction and names.
Both subject digest/path and inspection parameters must match normalized artifact
identity. Authenticate Evidence before projection; summary Evidence must be
resolved to its retained complete parent by the caller. Report unknown coverage
and explicit limitations. Exact static equality is not runtime compatibility,
ABI compatibility, linking success or source identity.
## Authority and overlap
Reads supplied Evidence only. MCP records derived Evidence through the existing
handler/ledger; CLI accepts portable inline Evidence from an explicit JSON input.
No new provider, implicit fetch, target mutation or foreign-code execution.
PR #2406 (head 86865035) adds component inspection, preamble routing and embedded
core-module inspection. Its component parser is not required or duplicated here;
shared contract/CLI registration changes remain additive. Core GC/recursive types
are outside admitted signature semantics and keep unknown mappings.
## Resource admission
Typed producer rows, names and declaration tokenization are admitted before
allocation against the existing 32 MiB WABT output representation capacity.
Comparison uses linear grouping and cooperative transport yields; it never
builds Cartesian duplicate pairings. CLI JSON input retains the shared file
admission gate. Token usage and peak process-family memory remain unmeasured.
## Verification
Original owned modules assembled using WABT 1.0.42 cover semantically equivalent
encodings, changes of kind/signature, names/duplicates, imported index spaces,
tables/memory/globals/tags, unknown types and historical incomplete Evidence.
Exercise authenticated identity, cancellation, retained lookup, CLI/MCP parity
and advertised schemas. Run focused existing WASM/Evidence tests, repository
checks and docs validation; independent subagent review before handoff.
Format reference: https://webassembly.github.io/spec/core/binary/modules.html
@@ -0,0 +1,8 @@
# Tasks
- [x] Confirm scope and overlap boundaries against #2406.
- [x] Record spec and implementation plan.
- [x] Implement typed producer interface and shared comparison.
- [x] Add CLI/MCP contracts and public workflow tests.
- [x] Run focused, static and documentation checks.
- [x] Resolve independent review findings and rerun relevant checks.
@@ -0,0 +1,47 @@
# Verification
Verified base: a2d8b94dd50dab8d966239dd63ab84718aa8b4b4 (fetched main).
The change was reapplied without conflicts and relevant checks rerun.
- Focused existing/new parser, provider, service, contract and CLI/MCP tests:
8 files / 56 tests passed.
- After the final partial-uniqueness correction, reran service and compiled
CLI/MCP boundary tests: 2 files / 5 tests passed.
- `npm run check`: typecheck, lint, formatting, knip, module boundaries and
architecture guards passed. Lint reports warnings; no errors.
- `npm run docs:generate`, `npm run docs:check` and
`npm run verify:test-discovery` passed; every test file was discovered exactly once.
- Real WABT 1.0.42 comparison through compiled CLI and stdio MCP passed:
equivalent padded LEB128 original encodings, function signatures, all admitted
kinds, custom memory page size, escaped/BOM names, empty interfaces, explicit
scope, duplicate imports and unadmitted exception reference signatures.
- Existing real WABT artifact verifier passed after correcting its stale inline
partial-observation assumption to resolve the MCP retained reference from the
Evidence bundle and authenticate the original record.
## Independent review
Three findings repaired at their owning boundary:
1. Memory page size omitted: now preserved, default 65536 canonicalized.
2. Shared signature expansion undercounted: charge every retained occurrence.
Reviewer independently measured the 1000-params/10000-exports case: original
262956-byte WAT produced 61824014-byte JSON before correction. It now retains
1949 observations in a partial facet, with 12046676-byte JSON and an explicit
capacity issue. Original WAT stays available; incomplete coverage prevents
absence and uniqueness claims.
3. Partial singleton uniqueness: comparison now returns unknown whenever either
selected inventory is incomplete, including matched names.
Final independent source review reported no actionable findings. Review does not
replace the executed tests or real-tool evidence.
## Scope and measurement limits
On the original implementation base f254f5a, the complete comparison verifier reported 7.98 seconds elapsed, 6.56 user and
3.13 system seconds, and 429441024 bytes maximum RSS via macOS `/usr/bin/time -l`.
These are command-accounting measurements, not aggregate concurrent process-family
memory or model tokens. Token usage and full process-family peak are unmeasured.
Real-tool verification covers macOS arm64 only; Linux/Windows remain unverified.
No module was instantiated/executed and no source was fetched. Static equality
is not runtime compatibility. Full CI is separate from this local verification record.
+6
View File
@@ -646,6 +646,12 @@ records independent symbol/directory/SHA oracles; it never executes the target.
Linux also builds stripped, ordinary-native and small malformed/ambiguous/layout
negative inputs. The optional Windows fixture workflow builds a PE on a Windows
runner; analyzing that PE on Linux does not verify a Windows Ghidra host.
On a local Windows host, the .NET 8 ILCompiler finds the linker only through
Visual Studio 2019 or 2022 (`vswhere -version [16,18)`). With only Visual
Studio Build Tools 2026 installed, open its x64 Developer Command Prompt.
In that same `cmd.exe` session, run `set IlcUseEnvironmentalTools=true` and
then `npm run build:fixtures:nativeaot`. Running `vcvars64.bat` as a child of
PowerShell does not preserve its environment in the parent PowerShell session.
Build the clean pinned upstream adapter with `build:ghidra:nativeaot`, then set
`REA_GHIDRA_NATIVEAOT_JAR`. Run `verify:ghidra:nativeaot -- symbols`, `-- stripped`,
+48
View File
@@ -13,6 +13,11 @@ Check `binary_session` for configured capability availability. A recognized
header is not validation of the complete module; missing WABT is reported
separately as an unavailable capability, not as missing native engine support.
The recognized WASM header is checked before automatic database routing from a
.hop filename. Renaming a module to .hop therefore keeps the static-inspection
diagnostic. An explicit database selection or raw/COM format selection retains
the caller's requested interpretation; ordinary .hop database routing is unchanged.
Set `REA_WABT_BIN_DIRECTORY` to an absolute directory containing executable
`wasm-validate`, `wasm-objdump` and `wasm2wat` (with `.exe` on Windows). Missing
executables, unsupported banners and inaccessible tools produce actionable
@@ -79,3 +84,46 @@ 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](https://github.com/morluto/rea/blob/main/third_party/wabt/README.md) and
[testing](testing.md#offline-wasm-artifacts) for real-tool verification.
## Compare core-module interfaces
Inspection adds producer-owned typed `interface` facts while keeping exact WAT
import/export forms. Function/tag signatures resolve type indices; table/memory
limits, memory page size and global value type/mutability remain explicit.
Unadmitted types (including GC reference signatures) retain null signatures and
issues. Exact decoded names retain escapes, empty names, BOM and punctuation.
This facet is absent in historical Evidence that did not examine typed interfaces.
`compare_wasm_interfaces` accepts two complete `inspect_wasm_artifact` Evidence
records, or exact retained references in the current MCP session, and required
`scope: "imports" | "exports" | "both"`. It reads no files and needs no active
WABT toolchain. CLI uses portable inline Evidence in one JSON document:
```bash
rea compare-wasm-interfaces comparison-input.json --json
```
```json
{
"left": { "kind": "retained-evidence", "evidence_id": "ev_<left-digest>" },
"right": { "kind": "retained-evidence", "evidence_id": "ev_<right-digest>" },
"scope": "both"
}
```
The references above are MCP-only; put complete Evidence objects in the CLI
input. Resolve summary delivery to its retained complete parent before comparing.
Results retain both input Evidence IDs, artifact paths/digests/lengths and every
selected occurrence. Unique names are added, removed, changed or unchanged;
duplicate names and unsupported signatures are unknown. Incomplete or historical
inventories never establish absence. Imports group by module/name, exports by
name, so a change of kind is visible. Output ordering is deterministic.
Static type equality does not establish runtime compatibility, ABI compatibility
or linking success. Comparison covers core modules; WebAssembly components and
canonical ABI relationships belong to their separate inspection workflow.
After building, run `REA_WABT_BIN_DIRECTORY=/absolute/wabt/bin node
scripts/verify-wasm-interface-comparison.mjs` for original-module comparison
through the compiled CLI and stdio MCP. It uses owned fixtures and WABT 1.0.42;
no WASM module is instantiated or executed.
+4 -2
View File
@@ -29,8 +29,10 @@ admitted raw NMOS 6502 mapping on Windows. Windows P0 admission remains limited
to native PE and raw NMOS 6502 images. For x64 PE
NativeAOT RTR 9.1, the read-only snapshot parser can report metadata through
`inspect_native_load_image` and `inspect_native_data_type` without a JAR or
database mutation. A real Windows-host NativeAOT Ghidra run is still required
to establish the complete host workflow.
database mutation: MethodTable relationships, virtual slots and frozen strings.
`analyze_function` decompiles slot targets. Database reads of the rehydrated
bytes (`read_bytes`, `search_strings`) return nothing on this host. See
[NativeAOT](ghidra-nativeaot.md).
This boundary does not establish general Windows feature parity. Ghidra GUI
state, annotations, other target architectures and formats, and Hopper-only
+1
View File
@@ -269,6 +269,7 @@
"verify:binary:layout": "npm run build:cached && node scripts/verify-binary-layout.mjs",
"verify:go:binary": "npm run build:cached && node scripts/verify-go-binary.mjs",
"verify:wasm:package": "npm run build:cached && node scripts/verify-packaged-wasm-artifact.mjs",
"verify:wasm-interface-comparison": "npm run build:cached && node scripts/verify-wasm-interface-comparison.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:solidity:metadata": "npm run build:cached && node scripts/verify-solidity-metadata.mjs",
+3 -1
View File
@@ -213,7 +213,9 @@ export const providerCatalog = (sources) => {
},
{
identity: sources.wasmProvider.WABT_PROVIDER_IDENTITY,
contracts: sources.wasmContracts.WASM_TOOL_CONTRACTS,
contracts: sources.wasmContracts.WASM_TOOL_CONTRACTS.filter(
({ kind }) => kind === "artifact-provider",
),
},
{
identity: sources.evmProvider.EVMOLE_PROVIDER_IDENTITY,
+17 -1
View File
@@ -310,7 +310,23 @@ try {
}),
);
assert.equal(error.category, "invalid_input");
const failed = parseEvidence(error.details.partial_observation);
const partial = error.details.partial_observation;
assert.equal(partial.kind, "retained-evidence");
const failedBundleReply = await client.callTool({
name: "get_evidence_bundle",
arguments: {},
});
assert.notEqual(
failedBundleReply.isError,
true,
mcpTextValue(failedBundleReply),
);
const failedBundle = JSON.parse(mcpTextValue(failedBundleReply)).result;
const failed = parseEvidence(
failedBundle.records.find(
({ evidence_id }) => evidence_id === partial.evidence_id,
),
);
assert.equal(failed.subject.digest.sha256, sha256(invalidBytes));
assert.equal(
failed.normalized_result.validation,
@@ -0,0 +1,208 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises";
import { join, resolve, isAbsolute } from "node:path";
import { tmpdir } from "node:os";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../dist/domain/evidence.js";
const execute = promisify(execFile);
const bin = process.env.REA_WABT_BIN_DIRECTORY;
if (!isAbsolute(bin ?? ""))
throw new Error("Supply absolute REA_WABT_BIN_DIRECTORY with WABT 1.0.42");
const root = await mkdtemp(join(tmpdir(), "rea-wasm-interface-real-"));
const cli = resolve("scripts/rea.mjs");
const environment = { ...process.env };
const command = async (file, args) =>
(
await execute(file, args, {
env: environment,
cwd: root,
timeout: 120000,
maxBuffer: 64 * 1024 * 1024,
})
).stdout;
const client = new Client({
name: "wasm-interface-real-verifier",
version: "1",
});
const transport = new StdioClientTransport({
command: process.execPath,
args: [cli, "mcp"],
env: environment,
stderr: "pipe",
});
const source = (parameter) =>
`(module (type (func (param ${parameter}) (result i64))) (import "env" "f" (func (type 0))) (table 1 3 funcref) (memory 1 2) (global (mut i64) (i64.const 0)) (tag (param i32)) (export "run\\22\\ef\\bb\\bf" (func 0)) (export "table" (table 0)) (export "memory" (memory 0)) (export "global" (global 0)) (export "tag" (tag 0)))`;
const assemble = async (name, wat) => {
await writeFile(join(root, `${name}.wat`), wat);
const path = join(root, `${name}.wasm`);
await command(join(bin, "wat2wasm"), [
"--enable-all",
`${name}.wat`,
"-o",
path,
]);
return path;
};
const inspect = async (path) => {
const result = await client.callTool({
name: "inspect_wasm_artifact",
arguments: { path },
});
assert.notEqual(result.isError, true, JSON.stringify(result));
return parseEvidence(result.structuredContent);
};
const compare = async (left, right, scope = "both") => {
const input = {
left: { kind: "retained-evidence", evidence_id: left.evidence_id },
right: { kind: "retained-evidence", evidence_id: right.evidence_id },
scope,
};
const reply = await client.callTool({
name: "compare_wasm_interfaces",
arguments: input,
});
assert.notEqual(reply.isError, true, JSON.stringify(reply));
const mcp = parseEvidence(reply.structuredContent);
const file = join(root, "comparison.json");
await writeFile(file, JSON.stringify({ left, right, scope }));
const local = parseEvidence(
JSON.parse(
await command(process.execPath, [
cli,
"compare-wasm-interfaces",
file,
"--json",
]),
),
);
assert.deepEqual(local, mcp);
return mcp.normalized_result;
};
try {
assert.equal(
(await command(join(bin, "wat2wasm"), ["--version"])).trim(),
"1.0.42",
);
await client.connect(transport);
const path = await assemble("baseline", source("i32"));
const original = await readFile(path);
assert.equal(original[8], 1);
assert.ok(original[9] < 128);
// Section lengths allow padded u32 LEB128 encodings. Different original
// byte identities must compare equal after validated type resolution.
const equivalentPath = join(root, "equivalent.wasm");
await writeFile(
equivalentPath,
Buffer.concat([
original.subarray(0, 9),
Buffer.from([original[9] | 128, 0]),
original.subarray(10),
]),
);
const left = await inspect(path);
const equivalent = await inspect(equivalentPath);
assert.notEqual(left.subject.digest.sha256, equivalent.subject.digest.sha256);
const same = await compare(left, equivalent);
assert.deepEqual(same.summary, {
added: 0,
removed: 0,
changed: 0,
unchanged: 6,
unknown: 0,
});
const facts = left.normalized_result.interface.entries;
assert.deepEqual(facts.find((e) => e.name === "memory").signature, {
kind: "memory",
page_size: "65536",
address_type: "i32",
minimum: "1",
maximum: "2",
shared: false,
});
assert.deepEqual(facts.find((e) => e.name === "tag").signature, {
kind: "tag",
params: ["i32"],
results: [],
});
assert.deepEqual(facts.find((e) => e.name === 'run"\ufeff').signature, {
kind: "func",
params: ["i32"],
results: ["i64"],
});
const changed = await inspect(await assemble("changed", source("i64")));
assert.deepEqual((await compare(left, changed)).summary, {
added: 0,
removed: 0,
changed: 2,
unchanged: 4,
unknown: 0,
});
const smallPage = await inspect(
await assemble(
"small-page",
'(module (memory 1 2 (pagesize 1)) (export "memory" (memory 0)))',
),
);
const defaultPage = await inspect(
await assemble(
"default-page",
'(module (memory 1 2) (export "memory" (memory 0)))',
),
);
assert.equal((await compare(defaultPage, smallPage)).summary.changed, 1);
const empty = await inspect(await assemble("empty", "(module)"));
assert.equal((await compare(left, empty)).summary.removed, 6);
assert.equal((await compare(empty, left, "exports")).summary.added, 5);
const duplicate = await inspect(
await assemble(
"duplicate",
'(module (import "e" "same" (func (param i32))) (import "e" "same" (func (param i64))))',
),
);
const duplicates = await compare(duplicate, duplicate, "imports");
assert.equal(duplicates.summary.unknown, 1);
assert.equal(duplicates.changes[0].left.length, 2);
const unsupported = await inspect(
await assemble(
"exnref",
'(module (import "e" "abstract-ref" (global exnref)))',
),
);
assert.equal((await compare(unsupported, unsupported)).summary.unknown, 1);
console.log(
JSON.stringify(
{
verified: true,
toolchain: "WABT 1.0.42",
transport: "compiled CLI + stdio MCP",
interface_fact_bytes: Buffer.byteLength(
JSON.stringify(left.normalized_result.interface),
),
comparison_result_bytes: Buffer.byteLength(JSON.stringify(same)),
original_sha256: left.subject.digest.sha256,
equivalent_sha256: equivalent.subject.digest.sha256,
cases: [
"equivalent padded LEB128",
"function signature change",
"all admitted kinds",
"custom memory page-size change",
"escaped/BOM name",
"empty interface additions/removals",
"scope",
"duplicate imports",
"unsupported exception reference",
],
runtime_execution: "not-performed",
},
null,
2,
),
);
} finally {
await client.close();
await rm(root, { recursive: true, force: true });
}
+62
View File
@@ -1127,6 +1127,68 @@ exit 1`,
expect(projected.message).toContain("not installed for 0");
});
// adb exits 1 on a refused package change: `adb install` after printing the
// status line to stderr, `cmd package uninstall` after printing it to stdout.
it("preserves the device's install refusal when adb exits nonzero", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "install" ]; then
printf 'Performing Streamed Install\\n'
printf 'adb: failed to install app.apk: Failure [INSTALL_FAILED_ALREADY_EXISTS: Attempt to re-install com.example without first uninstalling.]\\n' >&2
exit 1
fi
exit 1`,
);
const apk = join(root, "app.apk");
await writeFile(apk, "apk");
const projected = await refusedBy(providerOver(binary), {
operation: "install_adb_package",
input: { serial: "emulator-5554", apk_path: apk, replace: false },
});
expect(projected).toMatchObject({
code: "unsupported_target",
details: {
reason:
"INSTALL_FAILED_ALREADY_EXISTS: Attempt to re-install com.example without first uninstalling.",
},
});
expect(projected.message).toContain(
"INSTALL_FAILED_ALREADY_EXISTS: Attempt to re-install com.example without first uninstalling.",
);
});
it("preserves the device's uninstall refusal when adb exits nonzero", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "uninstall" ]; then printf 'Failure [not installed for 0]\\n'; exit 1; fi
exit 1`,
);
const projected = await refusedBy(providerOver(binary), {
operation: "uninstall_adb_package",
input: { serial: "emulator-5554", package: "com.absent" },
});
expect(projected).toMatchObject({
code: "unsupported_target",
details: { reason: "not installed for 0" },
});
expect(projected.message).toContain("not installed for 0");
});
it("keeps a nonzero install without a device verdict as an unreadable result", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "install" ]; then printf 'adb: failed to install app.apk: closed\\n' >&2; exit 1; fi
exit 1`,
);
const apk = join(root, "app.apk");
await writeFile(apk, "apk");
const projected = await refusedBy(providerOver(binary), {
operation: "install_adb_package",
input: { serial: "emulator-5554", apk_path: apk, replace: false },
});
expect(projected).toMatchObject({ code: "unreadable_output" });
});
it("starts an app through resolved launcher component and force-stops it", async () => {
const { binary } = await writeAdbStub(
shellStub({
+36 -2
View File
@@ -71,6 +71,7 @@ import {
parseBugreportCopiedPath,
parseDumpsysPackageOutput,
parseFeatureListOutput,
packageChangeFailureReason,
parsePackageChangeOutput,
parseProcessListOutput,
parsePngDimensions,
@@ -248,6 +249,31 @@ const runAdb = async (
});
};
/**
* adb exits nonzero when the device refuses a package change, printing the
* `Failure [...]` verdict to stderr (install) or stdout (uninstall). Read the
* refusal from that failed run so it is not reported as unreadable output.
*/
const packageChangeRefusal = (
operation: string,
serial: string,
error: AnalysisError,
): Result<never, AnalysisError> | undefined => {
if (!(error instanceof AnalysisProtocolError)) return undefined;
const failure = execFileOutputFailure(error.cause);
if (failure === undefined) return undefined;
const reason = packageChangeFailureReason(
`${failure.stdout}\n${failure.stderr}`,
);
return reason === null
? undefined
: err(
new AnalysisUnsupportedTargetError(operation, serial, reason, {
cause: error,
}),
);
};
const clientIdentityResult = (
selection: AdbBinarySelection,
identity: AdbClientIdentity,
@@ -1754,7 +1780,11 @@ export class AdbProvider {
{ timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES },
signal,
);
if (!installed.ok) return installed;
if (!installed.ok)
return (
packageChangeRefusal(operation, input.serial, installed.error) ??
installed
);
const verdict = parsePackageChangeOutput(installed.value.stdout);
if (verdict.status === "failure")
return err(
@@ -1803,7 +1833,11 @@ export class AdbProvider {
},
signal,
);
if (!uninstalled.ok) return uninstalled;
if (!uninstalled.ok)
return (
packageChangeRefusal(operation, input.serial, uninstalled.error) ??
uninstalled
);
const verdict = parsePackageChangeOutput(uninstalled.value.stdout);
if (verdict.status === "failure")
return err(
+7 -2
View File
@@ -293,6 +293,10 @@ export const parseBugreportCopiedPath = (stdout: string): string | null => {
return match === null ? null : match[1]!;
};
/** Read the device's `Failure [REASON]` verdict from package-change output. */
export const packageChangeFailureReason = (output: string): string | null =>
/Failure \[([^\]]+)\]/u.exec(output)?.[1] ?? null;
/**
* Parse `adb install`/`adb uninstall` output. adb can print `Failure [...]`
* with a zero exit code, so the text decides, not the exit status.
@@ -305,10 +309,11 @@ export const parsePackageChangeOutput = (
} => {
const text = stdout.trim();
if (/^Success$/mu.test(text)) return { status: "success", reason: null };
const failure = /Failure \[([^\]]+)\]/u.exec(text);
return {
status: "failure",
reason: failure?.[1] ?? (text === "" ? null : text.slice(0, 512)),
reason:
packageChangeFailureReason(text) ??
(text === "" ? null : text.slice(0, 512)),
};
};
+6 -1
View File
@@ -162,6 +162,12 @@ export const parseBinaryTarget = async (
availableArchitectures: ["x86"],
});
}
// Probe WASM content before suffix-only database routing; an explicit
// database selection keeps the caller's chosen interpretation.
const artifactFormat =
targetKind === "database"
? undefined
: await detectArtifactFormat(path, handle, signal);
if (
targetKind === "database" ||
(targetKind === undefined && path.toLowerCase().endsWith(".hop"))
@@ -173,7 +179,6 @@ export const parseBinaryTarget = async (
kind: "database",
format: "analysis-database",
});
const artifactFormat = await detectArtifactFormat(path, handle, signal);
if (artifactFormat !== undefined) {
const identity = {
path,
@@ -30,6 +30,29 @@ it("labels an envelope containing debugger-derived maps as derived", async () =>
expect(evidence.raw_result).toBeNull();
});
it.each([
["x86_64-little-endian", "x86_64"],
["aarch64-little-endian", "arm64"],
] as const)(
"names the CPU family carried by a %s core on created Evidence",
async (architecture, expected) => {
const fixture = recordedCrashFixture("/selected.core", architecture);
const service = new RecordedCrashService({
inspectAndClose(input, options) {
return this.inspect(input, options);
},
identity: RECORDED_CRASH_TEST_PROVIDER,
inspect: () => Promise.resolve(ok(fixture)),
});
const result = await service.inspect({
path: fixture.artifact.path,
include_debugger_context: false,
});
if (!result.ok) throw result.error;
expect(parseEvidence(result.value).subject?.architecture).toBe(expected);
},
);
it.each([
{ path: "relative.elf" },
{ path: "/selected.core", approval: true },
@@ -10,6 +10,7 @@ import {
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import {
inspectRecordedCrashInputSchema,
RECORDED_LINUX_CORE_PROFILES,
recordedCrashSchema,
} from "../../domain/native/recordedCrash.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
@@ -101,7 +102,9 @@ export class RecordedCrashService {
{
path: value.artifact.path,
format: "elf",
architecture: "x86_64",
architecture:
RECORDED_LINUX_CORE_PROFILES[value.architecture]
.evidenceArchitecture,
sha256: value.artifact.sha256,
},
this.provider.identity,
@@ -0,0 +1,198 @@
import { expect, it } from "vitest";
import { createEvidence } from "../../domain/evidence.js";
import { wasmInterfaceComparisonSchema } from "../../contracts/wasm/wasmComparisonContracts.js";
import { compareWasmInterfaces } from "./WasmInterfaceComparisonService.js";
import type { WasmInterface } from "../../domain/wasm/wasmInterface.js";
import { wasmArtifactFixture } from "../../../tests/fixtures/wasm/artifact.js";
const facts = (
name: string,
parameter: "i32" | "i64" = "i32",
): WasmInterface => ({
state: "complete",
issues: [],
entries: [
{
direction: "export",
module: null,
name,
kind: "func",
index: 0,
signature: { kind: "func", params: [parameter], results: [] },
wat_form: `(export "${name}" (func 0))`,
issue: null,
},
],
});
const evidence = (interfaceFacts?: WasmInterface) => {
const report = wasmArtifactFixture();
if (interfaceFacts !== undefined) report.interface = interfaceFacts;
return createEvidence(
{
path: report.artifact.path,
sha256: report.artifact.sha256,
format: "file",
},
{ id: "fixture", name: "typed fixture", version: "1" },
{
operation: "inspect_wasm_artifact",
parameters: { path: report.artifact.path },
result: report,
confidence: "observed",
},
);
};
const compare = async (
left: ReturnType<typeof evidence>,
right: ReturnType<typeof evidence>,
) => {
const result = await compareWasmInterfaces({ left, right, scope: "both" });
if (!result.ok) throw result.error;
return wasmInterfaceComparisonSchema.parse(result.value.normalized_result);
};
it("reports all five comparison states, including duplicate ambiguity and explicit selected scope", async () => {
const left = facts("changed");
const right = facts("changed", "i64");
left.entries.push(
...facts("unchanged").entries,
...facts("removed").entries,
...facts("dup").entries,
...facts("dup").entries,
);
right.entries.push(
...facts("unchanged").entries,
...facts("added").entries,
...facts("dup").entries,
);
const report = await compare(evidence(left), evidence(right));
expect(report.summary).toEqual({
changed: 1,
unchanged: 1,
removed: 1,
added: 1,
unknown: 1,
});
expect(report.changes.find((c) => c.name === "dup")).toMatchObject({
state: "unknown",
left: [expect.anything(), expect.anything()],
right: [expect.anything()],
});
const selected = await compareWasmInterfaces({
left: evidence(left),
right: evidence(right),
scope: "imports",
});
if (!selected.ok) throw selected.error;
expect(selected.value.normalized_result).toMatchObject({
changes: [],
coverage: "complete-within-inputs",
});
});
it("withholds absence claims for historical or partial inventories and unsupported signatures", async () => {
const known = facts("f");
const absent = await compare(evidence(), evidence(known));
expect(absent).toMatchObject({
coverage: "partial",
changes: [{ state: "unknown" }],
});
const partial: WasmInterface = {
state: "partial",
issues: ["unread import table"],
entries: [],
};
expect(await compare(evidence(partial), evidence(known))).toMatchObject({
changes: [{ state: "unknown" }],
});
const unsupported = facts("f");
unsupported.entries[0]!.signature = null;
expect(await compare(evidence(known), evidence(unsupported))).toMatchObject({
changes: [{ state: "unknown" }],
});
const partialMatch = facts("f");
partialMatch.state = "partial";
partialMatch.issues = ["Later imports were not examined"];
expect(await compare(evidence(partialMatch), evidence(known))).toMatchObject({
changes: [{ state: "unknown" }],
});
expect(await compare(evidence(), evidence())).toMatchObject({
coverage: "unknown",
changes: [],
});
});
it("authenticates semantic Evidence and binds display subject and selection identity", async () => {
const left = JSON.parse(JSON.stringify(evidence(facts("f"))));
left.normalized_result.interface.entries[0].name = "tampered";
expect(
await compareWasmInterfaces({
left,
right: evidence(facts("f")),
scope: "both",
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
const rebound = JSON.parse(JSON.stringify(evidence(facts("f"))));
rebound.subject.local_path = "/other.wasm";
expect(
await compareWasmInterfaces({
left: rebound,
right: evidence(facts("f")),
scope: "both",
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
});
it("resolves exact retained references and permits cancellation between comparison batches", async () => {
const left = evidence(facts("f"));
const right = evidence(facts("f", "i64"));
const references = {
left: { kind: "retained-evidence", evidence_id: left.evidence_id },
right: { kind: "retained-evidence", evidence_id: right.evidence_id },
scope: "both",
};
expect(await compareWasmInterfaces(references)).toMatchObject({
ok: false,
error: { _tag: "EvidenceIntegrityError" },
});
const result = await compareWasmInterfaces(references, {}, (id) =>
id === left.evidence_id ? left : right,
);
if (!result.ok) throw result.error;
expect(result.value.evidence_links).toEqual([
left.evidence_id,
right.evidence_id,
]);
const large = facts("f");
large.entries.push(
...Array.from({ length: 2000 }, (_, i) => facts(`f${i}`).entries[0]!),
);
const controller = new AbortController();
const pending = compareWasmInterfaces(
{ left: evidence(large), right: evidence(large), scope: "both" },
{ signal: controller.signal },
);
setImmediate(() => controller.abort());
expect(await pending).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
});
it.each([false, true])(
"keeps a one-sided unsupported interface unknown with reverse=%s",
async (reverse) => {
const empty: WasmInterface = { state: "complete", issues: [], entries: [] };
const unsupported = facts("exception");
unsupported.entries[0]!.signature = null;
unsupported.entries[0]!.issue = "Unsupported exnref global type";
const sides = reverse ? [unsupported, empty] : [empty, unsupported];
expect(await compare(evidence(sides[0]), evidence(sides[1]))).toMatchObject(
{
coverage: "partial",
summary: { added: 0, removed: 0, unknown: 1 },
changes: [{ state: "unknown" }],
},
);
},
);
@@ -0,0 +1,250 @@
import {
WASM_INTERFACE_REPRESENTATION_BYTES,
wasmInterfaceEntryRepresentationBytes,
} from "../../domain/wasm/wasmInterface.js";
import { setImmediate } from "node:timers/promises";
import {
compareWasmInterfacesInputSchema,
wasmInterfaceComparisonSchema,
} from "../../contracts/wasm/wasmComparisonContracts.js";
import {
createEvidence,
parseEvidence,
type Evidence,
} from "../../domain/evidence.js";
import {
wasmArtifactSchema,
type WasmArtifact,
} from "../../domain/wasm/wasmArtifact.js";
import type { WasmInterfaceEntry } from "../../domain/wasm/wasmInterface.js";
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../../domain/analysisErrorCore.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import { err, ok, type Result } from "../../domain/result.js";
import { ARTIFACT_COMPARISON_PROVIDER } from "../InvestigationProviders.js";
import {
resolvePairedEvidenceRequest,
type EvidenceLookup,
} from "../EvidenceInputResolver.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { z } from "zod";
const OPERATION = "compare_wasm_interfaces";
const parseInspection = (
raw: unknown,
): { evidence: Evidence; result: WasmArtifact } => {
const evidence = parseEvidence(raw);
if (evidence.operation !== "inspect_wasm_artifact")
throw new TypeError("Expected complete inspect_wasm_artifact Evidence");
const result = wasmArtifactSchema.parse(evidence.normalized_result);
if (
evidence.subject?.digest.sha256 !== result.artifact.sha256 ||
evidence.subject.local_path !== result.artifact.path ||
evidence.parameters.path !== result.artifact.path
)
throw new TypeError(
"WASM Evidence subject/selection does not match normalized artifact identity",
);
return { evidence, result };
};
/** Compare only producer-owned typed facts; no raw WAT or original file is reread. */
export const compareWasmInterfaces = async (
rawInput: unknown,
options?: ExecutionOptions,
lookup?: EvidenceLookup,
): Promise<Result<Evidence, AnalysisError>> => {
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(OPERATION));
const parsed = compareWasmInterfacesInputSchema.safeParse(rawInput);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(OPERATION, parsed.error.issues, rawInput),
);
const resolved = resolvePairedEvidenceRequest(parsed.data, lookup);
if (!resolved.ok) return resolved;
try {
await setImmediate();
options?.signal?.throwIfAborted();
const left = parseInspection(resolved.value.left);
const right = parseInspection(resolved.value.right);
const scope = parsed.data.scope;
const selected = (entry: WasmInterfaceEntry): boolean =>
scope === "both" ||
entry.direction === (scope === "imports" ? "import" : "export");
const groups = new Map<
string,
{
left: WasmInterfaceEntry[];
right: WasmInterfaceEntry[];
entry: WasmInterfaceEntry;
}
>();
let work = 0;
for (const [side, inspection] of [
["left", left],
["right", right],
] as const) {
for (const entry of inspection.result.interface?.entries ?? []) {
if (++work % 256 === 0) {
await setImmediate();
options?.signal?.throwIfAborted();
}
if (!selected(entry)) continue;
const key = JSON.stringify([entry.direction, entry.module, entry.name]);
let group = groups.get(key);
if (group === undefined) {
group = { left: [], right: [], entry };
groups.set(key, group);
}
group[side].push(entry);
}
}
const knownInventory = (inspection: typeof left): boolean =>
inspection.result.interface?.state === "complete";
const changes: z.infer<typeof wasmInterfaceComparisonSchema>["changes"] =
[];
const summary = {
added: 0,
removed: 0,
changed: 0,
unchanged: 0,
unknown: 0,
};
let remaining = WASM_INTERFACE_REPRESENTATION_BYTES;
for (const group of groups.values()) {
remaining -=
512 +
group.left.reduce(
(sum, entry) => sum + wasmInterfaceEntryRepresentationBytes(entry),
0,
) +
group.right.reduce(
(sum, entry) => sum + wasmInterfaceEntryRepresentationBytes(entry),
0,
);
if (remaining < 0)
throw new AnalysisResourceConstraintError(
OPERATION,
"capacity",
"WASM comparison exceeds typed interface representation capacity",
{ maximum_bytes: WASM_INTERFACE_REPRESENTATION_BYTES },
);
if (++work % 256 === 0) {
await setImmediate();
options?.signal?.throwIfAborted();
}
let state: keyof typeof summary;
let reason: string | null = null;
const l = group.left[0];
const r = group.right[0];
if (group.left.length > 1 || group.right.length > 1) {
state = "unknown";
reason =
"Duplicate interface names are ambiguous; all occurrences retained";
} else if (!knownInventory(left) || !knownInventory(right)) {
state = "unknown";
reason =
"Incomplete interface inventories cannot establish per-key uniqueness or absence";
} else if (
[l, r].some(
(entry) =>
entry !== undefined &&
(entry.signature === null ||
entry.issue !== null ||
entry.kind === "unknown"),
)
) {
state = "unknown";
reason =
"Signature/type semantics were not admitted for every present entry";
} else if (l === undefined) state = "added";
else if (r === undefined) state = "removed";
else if (l.kind !== r.kind) state = "changed";
else
state =
JSON.stringify(l.signature) === JSON.stringify(r.signature)
? "unchanged"
: "changed";
summary[state]++;
changes.push({
direction: group.entry.direction,
module: group.entry.module,
name: group.entry.name,
state,
left: group.left,
right: group.right,
reason,
});
}
const compareText = (a: string, b: string): number =>
a < b ? -1 : a > b ? 1 : 0;
changes.sort(
(a, b) =>
compareText(a.direction, b.direction) ||
compareText(a.module ?? "", b.module ?? "") ||
compareText(a.name, b.name),
);
const coverage =
left.result.interface === undefined &&
right.result.interface === undefined
? "unknown"
: !knownInventory(left) || !knownInventory(right) || summary.unknown > 0
? "partial"
: "complete-within-inputs";
const limitations = [
"Exact declared interface equality is not runtime/ABI compatibility, successful linking or original-source identity.",
"Typed facts come from the selected validated WABT decoding; unsupported signature semantics and duplicate names remain unknown.",
];
const report = wasmInterfaceComparisonSchema.parse({
left: {
evidence_id: left.evidence.evidence_id,
artifact: left.result.artifact,
},
right: {
evidence_id: right.evidence.evidence_id,
artifact: right.result.artifact,
},
scope,
coverage,
changes,
summary,
limitations,
});
await setImmediate();
options?.signal?.throwIfAborted();
return ok(
createEvidence(undefined, ARTIFACT_COMPARISON_PROVIDER, {
operation: OPERATION,
predicateType: "rea.wasm-interface-comparison",
parameters: {
left_evidence_id: left.evidence.evidence_id,
right_evidence_id: right.evidence.evidence_id,
scope,
},
result: report,
confidence: "derived",
authority: "analyst-inference",
limitations,
evidenceLinks: [left.evidence.evidence_id, right.evidence.evidence_id],
}),
);
} catch (cause: unknown) {
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(OPERATION));
if (cause instanceof AnalysisError) return err(cause);
return err(
new AnalysisInputError(OPERATION, { cause }, [
{
path: [],
reason: "invalid_value",
message: cause instanceof Error ? cause.message : String(cause),
},
]),
);
}
};
+26
View File
@@ -1,3 +1,6 @@
import { compareWasmInterfaces } from "../application/wasm/WasmInterfaceComparisonService.js";
import { parseCliJsonInput } from "../cliJsonInput.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { z } from "incur";
import { resolve } from "node:path";
import { wasmArtifactModule } from "../composition/wasm.js";
@@ -13,6 +16,29 @@ export const registerWasmCommands = (
logger: Logger,
environment: Readonly<NodeJS.ProcessEnv>,
): void => {
cli.command(CLI_COMMANDS.compareWasmInterfaces, {
description:
"Compare core WASM interfaces from two exact inspection Evidence records",
args: z.object({
input: z
.string()
.describe(
"Inline JSON or input file containing left/right complete Evidence and required scope",
),
}),
run: ({ args }) =>
withCommandCancellation(async (signal) =>
logCliCommand(logger, CLI_COMMANDS.compareWasmInterfaces, async () => {
const input = await parseCliJsonInput(
args.input,
"compare_wasm_interfaces",
);
if (!input.ok) return input.error;
const result = await compareWasmInterfaces(input.value, { signal });
return result.ok ? result.value : projectAnalysisError(result.error);
}),
),
});
const service = wasmArtifactModule.create(environment);
cli.command(wasmArtifactModule.cliCommand, {
description: "Inspect an explicit local WASM artifact with WABT",
+1
View File
@@ -4,6 +4,7 @@ export const CLI_COMMANDS = Object.freeze({
inspectPacketCapture: "inspect-packet-capture",
inspectSignalDecoders: "inspect-signal-decoders",
decodeSignalRecording: "decode-signal-recording",
compareWasmInterfaces: "compare-wasm-interfaces",
inspectWasmArtifact: "inspect-wasm-artifact",
inspectSqliteDatabase: "inspect-sqlite-database",
inspectEvmInterface: "inspect-evm-interface",
+74
View File
@@ -30,6 +30,7 @@ const resolveReference = (
root: Readonly<Record<string, unknown>>,
reference: string,
): unknown => {
if (reference === "#") return root;
if (!reference.startsWith("#/")) return undefined;
let value: unknown = root;
for (const token of reference.slice(2).split("/")) {
@@ -229,3 +230,76 @@ export const presentInputJsonSchema = (
const described = describeSchema(flattened, flattened, describeProperty);
return isObject(described) ? described : flattened;
};
/**
* Clients that render advertised inputs into SDK declarations expand
* schema-local references with a shared per-input budget: Pi 1.1.0 code-mode
* allows 32 expansions and degrades every later reference to `unknown`
* (morluto/rea#1632), hiding the fields those references describe. The budget
* counts conservatively — every resolvable, non-cyclic reference occurrence
* in every schema position — so any reference-resolving client stays within
* its measured limit.
*/
export const advertisedReferenceExpansionBudget = 32;
const definitionKeys = new Set(["$defs", "definitions"]);
const countReferenceExpansions = (
node: unknown,
root: Readonly<Record<string, unknown>>,
active: ReadonlySet<string>,
count: { value: number },
): void => {
if (Array.isArray(node)) {
node.forEach((child) =>
countReferenceExpansions(child, root, active, count),
);
return;
}
if (!isObject(node)) return;
if (typeof node.$ref === "string" && !active.has(node.$ref)) {
const target = resolveReference(root, node.$ref);
if (target !== undefined) {
count.value += 1;
countReferenceExpansions(
target,
root,
new Set([...active, node.$ref]),
count,
);
return;
}
}
for (const [key, value] of Object.entries(node)) {
if (definitionKeys.has(key)) continue;
if (schemaMaps.has(key) && isObject(value)) {
for (const child of Object.values(value))
countReferenceExpansions(child, root, active, count);
} else if (schemaArrays.has(key) || schemaChildren.has(key)) {
countReferenceExpansions(value, root, active, count);
}
}
};
/** Conservative client expansion count over every schema position. */
export const countLocalReferenceExpansions = (
schema: Record<string, unknown>,
): number => {
const count = { value: 0 };
countReferenceExpansions(schema, schema, new Set(), count);
return count.value;
};
/**
* Keep the shared-definition form of an advertised input while clients can
* still resolve it. Once rendering would exhaust the reference budget, fall
* back to the inline projection, which carries no shared definitions for the
* nonrecursive schemas this repository advertises, so every field renders
* with complete structure in every reference-resolving client.
*/
export const inputSchemaWithinReferenceExpansionBudget = (
shared: Record<string, unknown>,
inline: () => Record<string, unknown>,
budget: number,
): Record<string, unknown> =>
countLocalReferenceExpansions(shared) <= budget ? shared : inline();
@@ -67,7 +67,7 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_recorded_crash"),
kind: "native-provider",
description:
"Inspect a supplied Linux ELF64 amd64 little-endian core without launching its target or attaching to any live PID. Returns all recorded segments, raw note owners/descriptors and source ranges, every Linux CORE PRSTATUS thread/register and SIGINFO fields inline with SHA-256 Evidence. Historical PID is recorded metadata; current process/file identity, note coverage and signal-thread associations stay unknown. Optionally add derived core-only GDB/pwndbg mapping candidates through explicit BYO configuration; zero map flags mean unknown permissions. Uses the same stable snapshot for both stages, bounded complete output and owned cleanup. Initial Linux x64 profile: unchanged pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2; optional unchanged pwndbg 2026.09.15. No implicit executable/library pairing, GDB command language, host configuration changes or dependency installation.",
"Inspect a supplied Linux ELF64 amd64 or AArch64 little-endian core without launching its target or attaching to any live PID. Returns all recorded segments, raw note owners/descriptors and source ranges, every Linux CORE PRSTATUS thread/register and SIGINFO fields inline with SHA-256 Evidence. Historical PID is recorded metadata; current process/file identity, note coverage and signal-thread associations stay unknown. Optionally add derived core-only GDB/pwndbg mapping candidates through explicit BYO configuration; zero map flags mean unknown permissions. Uses the same stable snapshot for both stages, bounded complete output and owned cleanup. Linux x64 host profile: unchanged pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2; optional unchanged pwndbg 2026.09.15. No implicit executable/library pairing, GDB command language, host configuration changes or dependency installation.",
inputSchema: inspectRecordedCrashInputSchema,
outputSchema: evidenceResultOf(recordedCrashSchema),
examples: [
+2
View File
@@ -27,6 +27,8 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly<Record<string, string>> = {
inspect_macho: "runs host Mach-O inspection tools, not the target",
inspect_evm_interface:
"decodes bytecode in REA's bundled worker without executing it",
compare_wasm_interfaces:
"compares retained inspection Evidence without executing modules",
inspect_wasm_artifact:
"WABT validates and decompiles the module without executing it",
identify_flutter_build:
+1
View File
@@ -623,6 +623,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
}),
compare_process_captures: sessionEvidence,
compare_artifacts: sessionEvidence,
compare_wasm_interfaces: sessionEvidence,
compare_functions: sessionEvidence,
compare_bundles: effects({ mutatesSession: true }),
find_changed_behavior: sessionEvidence,
+105 -1
View File
@@ -3,7 +3,12 @@ import { Ajv2020 } from "ajv/dist/2020.js";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { presentInputJsonSchema } from "./inputSchemaPresentation.js";
import {
advertisedReferenceExpansionBudget,
countLocalReferenceExpansions,
inputSchemaWithinReferenceExpansionBudget,
presentInputJsonSchema,
} from "./inputSchemaPresentation.js";
import { toolContract } from "./toolContracts.js";
import { toolInputSchemaWithMetadata } from "./toolSchemaMetadata.js";
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "./javascript/javascriptRuntimeReconciliationExample.js";
@@ -166,6 +171,105 @@ describe("advertised input references", () => {
);
});
describe("advertised reference expansion budget", () => {
it("counts references in keyword-named fields, excluding literals", () => {
const fields = ["default", "enum", "examples", "const", "$defs"];
const schema = {
type: "object",
properties: Object.fromEntries(
fields.map((name) => [name, { $ref: "#/$defs/value" }]),
),
$defs: { value: { type: "string" } },
default: { $ref: "#/$defs/value" },
examples: [{ $ref: "#/$defs/value" }],
};
expect(countLocalReferenceExpansions(schema)).toBe(fields.length);
});
const reusedComponent = z
.object({ kind: z.literal("rec"), value: z.string() })
.strict();
const inputWithReusedFields = (count: number) =>
z.strictObject(
Object.fromEntries(
Array.from({ length: count }, (_, index) => [
`field_${String(index).padStart(2, "0")}`,
reusedComponent,
]),
),
);
const advertisedInput = (
canonical: z.ZodType<Readonly<Record<string, unknown>>>,
) =>
toolInputSchemaWithMetadata({
...toolContract("open_binary"),
inputSchema: canonical,
})["~standard"].jsonSchema.input({ target: "draft-2020-12" });
it("keeps shared definitions while every client can resolve them", () => {
const advertised = advertisedInput(inputWithReusedFields(8));
expect(advertised.$defs).toBeDefined();
expect(countLocalReferenceExpansions(advertised)).toBe(8);
});
it("keeps shared definitions exactly at the budget", () => {
const advertised = advertisedInput(
inputWithReusedFields(advertisedReferenceExpansionBudget),
);
expect(advertised.$defs).toBeDefined();
expect(countLocalReferenceExpansions(advertised)).toBe(
advertisedReferenceExpansionBudget,
);
});
it("re-projects without shared definitions once the budget is exceeded", () => {
const advertised = advertisedInput(
inputWithReusedFields(advertisedReferenceExpansionBudget + 1),
);
expect(advertised.$defs).toBeUndefined();
expect(countLocalReferenceExpansions(advertised)).toBe(0);
const properties = advertised.properties as Record<string, unknown>;
expect(properties.field_00).toMatchObject({
type: "object",
properties: { kind: { const: "rec" }, value: { type: "string" } },
required: ["kind", "value"],
});
});
it("keeps the shared form untouched when it fits and swaps it when not", () => {
const shared = {
type: "object",
properties: { value: { $ref: "#/$defs/shared" } },
$defs: { shared: { type: "string" } },
};
const inlined = {
type: "object",
properties: { value: { type: "string" } },
};
expect(
inputSchemaWithinReferenceExpansionBudget(
shared,
() => inlined,
advertisedReferenceExpansionBudget,
),
).toBe(shared);
expect(
inputSchemaWithinReferenceExpansionBudget(
{
...shared,
properties: Object.fromEntries(
Array.from({ length: 33 }, (_, index) => [
`field_${index}`,
{ $ref: "#/$defs/shared" },
]),
),
},
() => inlined,
advertisedReferenceExpansionBudget,
),
).toBe(inlined);
});
});
describe("root input presentation", () => {
it("preserves prototype-named properties in object-root presentation", () => {
const fields = Object.fromEntries([
+24 -2
View File
@@ -2,7 +2,11 @@ import { z } from "zod";
import type { ToolContract } from "./toolContractTypes.js";
import { advertisedInputExamples } from "./advertisedInputExamples.js";
import { presentInputJsonSchema } from "./inputSchemaPresentation.js";
import {
advertisedReferenceExpansionBudget,
inputSchemaWithinReferenceExpansionBudget,
presentInputJsonSchema,
} from "./inputSchemaPresentation.js";
import { digestCanonicalValue } from "../domain/canonicalDigest.js";
const PROPERTY_DESCRIPTIONS: Readonly<Record<string, string>> = {
@@ -66,7 +70,25 @@ export const toolInputSchemaWithMetadata = <Contract extends ToolContract>(
});
const examples = advertisedInputExamples(contract);
return {
...presentInputJsonSchema(shared, fallbackPropertyDescription),
// Sharing definitions only helps clients that resolve them. A client
// rendering declarations from a shared expansion budget degrades
// every reference past it to `unknown`, so an input whose shared form
// exceeds the budget is re-projected without shared definitions.
...inputSchemaWithinReferenceExpansionBudget(
presentInputJsonSchema(shared, fallbackPropertyDescription),
() =>
presentInputJsonSchema(
project({
...options,
libraryOptions: {
...options.libraryOptions,
reused: "inline",
},
}),
fallbackPropertyDescription,
),
advertisedReferenceExpansionBudget,
),
...(examples.length === 0 ? {} : { examples }),
};
},
@@ -0,0 +1,63 @@
import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
import { z } from "zod";
import { evidenceInputSchema } from "../evidenceInputContracts.js";
import { wasmInterfaceSchema } from "../../domain/wasm/wasmInterface.js";
import { toolContractMetadata } from "../toolEffects.js";
import type { ToolContract } from "../toolContractTypes.js";
export const compareWasmInterfacesInputSchema = z.strictObject({
left: evidenceInputSchema.describe(
"Exact complete inspect_wasm_artifact Evidence or retained session reference",
),
right: evidenceInputSchema.describe(
"Exact complete inspect_wasm_artifact Evidence or retained session reference",
),
scope: z
.enum(["imports", "exports", "both"])
.describe(
"Explicit interface selection; static equality does not establish runtime compatibility",
),
});
const identity = z.object({
evidence_id: z.string(),
artifact: z.object({
path: z.string(),
sha256: z.string(),
bytes: z.number().int().nonnegative(),
}),
});
export const wasmInterfaceComparisonSchema = z.object({
left: identity,
right: identity,
scope: z.enum(["imports", "exports", "both"]),
coverage: z.enum(["complete-within-inputs", "partial", "unknown"]),
changes: z.array(
z.object({
direction: z.enum(["import", "export"]),
module: z.string().nullable(),
name: z.string(),
state: z.enum(["added", "removed", "changed", "unchanged", "unknown"]),
left: wasmInterfaceSchema.shape.entries,
right: wasmInterfaceSchema.shape.entries,
reason: z.string().nullable(),
}),
),
summary: z.object({
added: z.number(),
removed: z.number(),
changed: z.number(),
unchanged: z.number(),
unknown: z.number(),
}),
limitations: z.array(z.string()),
});
export const COMPARE_WASM_INTERFACES_CONTRACT = {
name: "compare_wasm_interfaces",
...toolContractMetadata("compare_wasm_interfaces"),
kind: "application",
description:
"Compare typed core WASM import/export names, kinds and exact signatures from two authenticated inspect_wasm_artifact Evidence records. Select imports, exports or both explicitly. Retained references resolve only in the current session; CLI accepts portable inline Evidence. Duplicate names, unsupported types and missing historical interface facets remain unknown. Preserve original module digests and every occurrence; no source acquisition, module execution or runtime-compatibility inference.",
inputSchema: compareWasmInterfacesInputSchema,
outputSchema: evidenceResultOf(wasmInterfaceComparisonSchema),
examples: [],
} as const satisfies ToolContract;
+5 -1
View File
@@ -1,3 +1,4 @@
import { COMPARE_WASM_INTERFACES_CONTRACT } from "./wasmComparisonContracts.js";
import {
inspectWasmArtifactInputSchema,
wasmArtifactSchema,
@@ -40,4 +41,7 @@ export const INSPECT_WASM_ARTIFACT_CONTRACT = {
} as const satisfies ToolContract;
/** Keep the public catalog in its existing order, using its named contract. */
export const WASM_TOOL_CONTRACTS = [INSPECT_WASM_ARTIFACT_CONTRACT] as const;
export const WASM_TOOL_CONTRACTS = [
INSPECT_WASM_ARTIFACT_CONTRACT,
COMPARE_WASM_INTERFACES_CONTRACT,
] as const;
+94 -1
View File
@@ -1,7 +1,8 @@
import { expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import {
inspectRecordedCrashInputSchema,
recordedCrashSchema,
type RecordedCrash,
} from "./recordedCrash.js";
import {
recordedCrashFixture,
@@ -282,3 +283,95 @@ it("keeps absent map flags unknown and validates reported permission bits", () =
}).success,
).toBe(false);
});
describe("recorded AArch64 core profile", () => {
it("accepts the complete AArch64 register window in ABI order", () => {
const value = recordedCrashSchema.parse(
recordedCrashFixture("/selected/aarch64.core", "aarch64-little-endian"),
);
expect(value.architecture).toBe("aarch64-little-endian");
const registers = value.threads[0]?.registers ?? [];
expect(registers.map(({ name }) => name)).toEqual([
...Array.from({ length: 31 }, (_, index) => `x${String(index)}`),
"sp",
"pc",
"pstate",
]);
expect(registers[14]?.value).toBe("0x1122334455667788");
expect(registers.every(({ location }) => location.bytes === "0x8")).toBe(
true,
);
});
it("keeps the AArch64 note ranges inside their recorded segment", () => {
const value = recordedCrashSchema.parse(
recordedCrashSignalFixture(undefined, "aarch64-little-endian"),
);
const note = value.notes[0];
const segment = value.segments[0];
if (note === undefined || segment === undefined)
throw new Error("Expected recorded notes");
expect(
BigInt(note.location.offset) + BigInt(note.location.bytes),
).toBeLessThanOrEqual(BigInt(segment.offset) + BigInt(segment.file_size));
expect(value.signals[0]?.fault_address).toBe("0x1122334455667788");
});
it.each([
[
"an amd64 register name inside an AArch64 window",
(v: RecordedCrash) => ({
...v,
threads: v.threads.map((thread) => ({
...thread,
registers: thread.registers.map((register, index) =>
index === 0 ? { ...register, name: "rax" } : register,
),
})),
}),
],
[
"the amd64 register count",
(v: RecordedCrash) => ({
...v,
threads: v.threads.map((thread) => ({
...thread,
registers: thread.registers.slice(0, 27),
})),
}),
],
[
"a 4-byte AArch64 register source",
(v: RecordedCrash) => ({
...v,
threads: v.threads.map((thread) => ({
...thread,
registers: thread.registers.map((register, index) =>
index === 0
? {
...register,
location: { ...register.location, bytes: "0x4" },
}
: register,
),
})),
}),
],
] as const)("rejects %s", (_name, change) => {
const value = recordedCrashFixture(
"/selected/aarch64.core",
"aarch64-little-endian",
);
expect(recordedCrashSchema.safeParse(change(value)).success).toBe(false);
});
it("rejects an architecture outside the interpreted profiles", () => {
const value = recordedCrashFixture();
expect(
recordedCrashSchema.safeParse({
...value,
architecture: "arm-little-endian",
}).success,
).toBe(false);
});
});
+97 -6
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import type { BinaryArchitecture } from "../binaryTargetTypes.js";
import { localPathStringSchema } from "../localPath.js";
import { CANONICAL_BASE64_PATTERN } from "../stringPatterns.js";
import { recordedNoteCoverageIssues } from "./recordedCrashNoteCoverage.js";
@@ -18,7 +19,7 @@ const diagnostics = z.strictObject({
const LINUX_CORE_OWNER_BASE64 = "Q09SRQA=";
/** Complete register identities in the supported Linux amd64 PRSTATUS ABI order. */
export const RECORDED_LINUX_AMD64_REGISTERS: readonly string[] = [
const RECORDED_LINUX_AMD64_REGISTERS: readonly string[] = [
"r15",
"r14",
"r13",
@@ -47,7 +48,95 @@ export const RECORDED_LINUX_AMD64_REGISTERS: readonly string[] = [
"fs",
"gs",
];
const supportedRegisters = new Set(RECORDED_LINUX_AMD64_REGISTERS);
const RECORDED_LINUX_AARCH64_REGISTERS: readonly string[] = [
"x0",
"x1",
"x2",
"x3",
"x4",
"x5",
"x6",
"x7",
"x8",
"x9",
"x10",
"x11",
"x12",
"x13",
"x14",
"x15",
"x16",
"x17",
"x18",
"x19",
"x20",
"x21",
"x22",
"x23",
"x24",
"x25",
"x26",
"x27",
"x28",
"x29",
"x30",
"sp",
"pc",
"pstate",
];
/** Recorded-core architectures whose Linux note ABI is interpreted. */
export const RECORDED_LINUX_CORE_ARCHITECTURES = [
"x86_64-little-endian",
"aarch64-little-endian",
] as const;
export type RecordedCrashArchitecture =
(typeof RECORDED_LINUX_CORE_ARCHITECTURES)[number];
/**
* ABI facts for one recorded-core architecture, measured from unchanged pwntools
* 4.15.0 `elf_prstatus_*` and `user_regs_struct_*` structures and mirrored by
* `bridge/pwntools/recorded_crash_impl.py`. Both profiles place `pr_cursig` at
* 12, `pr_pid` at 32 and the register window at 112; the ELF machine, the window
* contents and the Evidence CPU family differ per profile.
*/
export const RECORDED_LINUX_CORE_PROFILES: Record<
RecordedCrashArchitecture,
{
readonly machine: number;
readonly evidenceArchitecture: BinaryArchitecture;
readonly registers: readonly string[];
readonly registerBytes: number;
readonly registerOffset: number;
readonly prstatusBytes: number;
}
> = {
"x86_64-little-endian": {
machine: 62,
evidenceArchitecture: "x86_64",
registers: RECORDED_LINUX_AMD64_REGISTERS,
registerBytes: 8,
registerOffset: 112,
prstatusBytes: 336,
},
"aarch64-little-endian": {
machine: 183,
evidenceArchitecture: "arm64",
registers: RECORDED_LINUX_AARCH64_REGISTERS,
registerBytes: 8,
registerOffset: 112,
prstatusBytes: 392,
},
};
const recordedCoreRegisterSets: Record<
RecordedCrashArchitecture,
ReadonlySet<string>
> = {
"x86_64-little-endian": new Set(RECORDED_LINUX_AMD64_REGISTERS),
"aarch64-little-endian": new Set(RECORDED_LINUX_AARCH64_REGISTERS),
};
/** Select an explicit recording and optionally request debugger-derived mapping context. */
export const inspectRecordedCrashInputSchema = z.strictObject({
@@ -108,7 +197,7 @@ const recordedCrashObjectSchema = z.strictObject({
bytes: index,
}),
format: z.literal("elf-core"),
architecture: z.literal("x86_64-little-endian"),
architecture: z.enum(RECORDED_LINUX_CORE_ARCHITECTURES),
target_execution: z.literal("not-performed"),
live_process_identity: z.literal("unknown"),
segments: z.array(
@@ -284,6 +373,8 @@ export const recordedCrashSchema = recordedCrashObjectSchema.superRefine(
"Each supported raw note requires exactly one corresponding interpretation.",
);
}
const profile = RECORDED_LINUX_CORE_PROFILES[value.architecture];
const profileRegisters = recordedCoreRegisterSets[value.architecture];
for (const thread of value.threads) {
const note = value.notes[thread.note_index];
if (
@@ -295,8 +386,8 @@ export const recordedCrashSchema = recordedCrashObjectSchema.superRefine(
continue;
}
if (
thread.registers.length !== RECORDED_LINUX_AMD64_REGISTERS.length ||
!thread.registers.every(({ name }) => supportedRegisters.has(name)) ||
thread.registers.length !== profile.registers.length ||
!thread.registers.every(({ name }) => profileRegisters.has(name)) ||
new Set(thread.registers.map(({ name }) => name)).size !==
thread.registers.length
)
@@ -305,7 +396,7 @@ export const recordedCrashSchema = recordedCrashObjectSchema.superRefine(
);
for (const register of thread.registers)
if (
BigInt(register.location.bytes) !== 8n ||
BigInt(register.location.bytes) !== BigInt(profile.registerBytes) ||
!within(
register.location,
BigInt(note.descriptor_location.offset),
+2
View File
@@ -1,3 +1,4 @@
import { wasmInterfaceSchema } from "./wasmInterface.js";
import { z } from "zod";
import { createHash } from "node:crypto";
import type { EvidenceSubjectTarget } from "../evidence.js";
@@ -32,6 +33,7 @@ export const wasmArtifactSchema = z
description: z.string(),
}),
),
interface: wasmInterfaceSchema.exactOptional(),
imports: z.array(z.string()),
exports: z.array(z.string()),
headers: z.string(),
+109
View File
@@ -0,0 +1,109 @@
import { z } from "zod";
const kind = z.enum(["func", "table", "memory", "global", "tag", "unknown"]);
const valueType = z.enum([
"i32",
"i64",
"f32",
"f64",
"v128",
"funcref",
"externref",
]);
const limit = z.string().regex(/^(?:0|[1-9][0-9]*)$/u);
const signature = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("func"),
params: z.array(valueType),
results: z.array(valueType),
}),
z.object({
kind: z.literal("tag"),
params: z.array(valueType),
results: z.array(valueType),
}),
z.object({
kind: z.literal("table"),
element: z.enum(["funcref", "externref"]),
address_type: z.enum(["i32", "i64"]),
minimum: limit,
maximum: limit.nullable(),
shared: z.boolean(),
}),
z.object({
kind: z.literal("memory"),
page_size: limit,
address_type: z.enum(["i32", "i64"]),
minimum: limit,
maximum: limit.nullable(),
shared: z.boolean(),
}),
z.object({
kind: z.literal("global"),
value_type: valueType,
mutable: z.boolean(),
}),
]);
/** Typed observations decoded once at the WABT producer boundary. */
export const wasmInterfaceSchema = z.object({
state: z.enum(["complete", "partial"]),
entries: z.array(
z
.object({
direction: z.enum(["import", "export"]),
module: z.string().nullable(),
name: z.string(),
kind,
index: z.number().int().nonnegative().max(0xffff_ffff).nullable(),
signature: signature.nullable(),
wat_form: z.string(),
issue: z.string().nullable(),
})
.superRefine((entry, context) => {
if (
(entry.direction === "export" && entry.module !== null) ||
(entry.direction === "import" && entry.module === null)
)
context.addIssue({
code: "custom",
message: "Interface direction does not match module-name presence",
});
if (entry.signature !== null && entry.signature.kind !== entry.kind)
context.addIssue({
code: "custom",
message: "Interface kind does not match its typed signature",
});
}),
),
issues: z.array(z.string()),
});
export type WasmInterface = z.infer<typeof wasmInterfaceSchema>;
export type WasmInterfaceEntry = WasmInterface["entries"][number];
export type WasmInterfaceSignature = NonNullable<
WasmInterfaceEntry["signature"]
>;
export type WasmInterfaceValueType = z.infer<typeof valueType>;
/** Bound eager typed output and repeated signature expansion independently of WAT bytes. */
export const WASM_INTERFACE_REPRESENTATION_BYTES = 32 * 1024 * 1024;
/** Conservative JSON/retention charge; shared arrays still expand at every occurrence. */
export const wasmInterfaceEntryRepresentationBytes = (
entry: WasmInterfaceEntry,
): number => {
let bytes =
1024 +
[entry.module, entry.name, entry.wat_form, entry.issue].reduce<number>(
(sum, value) => sum + (value?.length ?? 0) * 6,
0,
);
const signature = entry.signature;
if (signature?.kind === "func" || signature?.kind === "tag")
bytes += (signature.params.length + signature.results.length) * 16;
else if (signature !== null)
for (const value of Object.values(signature))
if (typeof value === "string") bytes += value.length * 6;
return bytes;
};
+55 -2
View File
@@ -63,6 +63,56 @@ const DYLIB_FIELDS = [
const DYLIB_ENCODING_FIELDS = ["time stamp", "options"];
const EMPTY_VALUE_FIELDS = new Set(["options", "segname", "sectname"]);
/**
* Non-verbose otool prints LC_BUILD_VERSION platform and tool codes as
* numbers. Name the codes declared in the SDK's <mach-o/loader.h>, without
* the PLATFORM_/TOOL_ prefix, so build metadata uses the same symbolic style
* as LC_VERSION_MIN_* platforms. Unknown codes keep their printed value.
*/
const BUILD_PLATFORM_NAMES: ReadonlyMap<string, string> = new Map([
["1", "MACOS"],
["2", "IOS"],
["3", "TVOS"],
["4", "WATCHOS"],
["5", "BRIDGEOS"],
["6", "MACCATALYST"],
["7", "IOSSIMULATOR"],
["8", "TVOSSIMULATOR"],
["9", "WATCHOSSIMULATOR"],
["10", "DRIVERKIT"],
["11", "VISIONOS"],
["12", "VISIONOSSIMULATOR"],
["13", "FIRMWARE"],
["14", "SEPOS"],
["15", "MACOS_EXCLAVECORE"],
["16", "MACOS_EXCLAVEKIT"],
["17", "IOS_EXCLAVECORE"],
["18", "IOS_EXCLAVEKIT"],
["19", "TVOS_EXCLAVECORE"],
["20", "TVOS_EXCLAVEKIT"],
["21", "WATCHOS_EXCLAVECORE"],
["22", "WATCHOS_EXCLAVEKIT"],
["23", "VISIONOS_EXCLAVECORE"],
["24", "VISIONOS_EXCLAVEKIT"],
]);
const BUILD_TOOL_NAMES: ReadonlyMap<string, string> = new Map([
["1", "CLANG"],
["2", "SWIFT"],
["3", "LD"],
["4", "LLD"],
["1024", "METAL"],
["1025", "AIRLLD"],
["1026", "AIRNT"],
["1027", "AIRNT_PLUGIN"],
["1028", "AIRPACK"],
["1031", "GPUARCHIVER"],
["1032", "METAL_FRAMEWORK"],
]);
const buildCodeName = (
names: ReadonlyMap<string, string>,
value: string | null,
): string | null => (value === null ? null : (names.get(value) ?? value));
/**
* otool emits LF, but a capture replayed from another host or toolchain can
* carry CRLF. The section/command boundaries below match a bare `\n`, so a
@@ -295,7 +345,10 @@ const collectBuild = (
if (numberField(fields, "ntools") !== tools.length)
throw new TypeError("otool build-tool count does not match ntools");
state.builds.push({
platform: stringField(fields, "platform"),
platform: buildCodeName(
BUILD_PLATFORM_NAMES,
stringField(fields, "platform"),
),
minimum_os: stringField(fields, "minos"),
sdk: stringField(fields, "sdk"),
tools,
@@ -434,7 +487,7 @@ const parseBuildTools = (
const tool = /^tool\s+(.+)$/u.exec(lines[index] ?? "")?.[1];
const version = /^version\s+(.+)$/u.exec(lines[index + 1] ?? "")?.[1];
if (tool !== undefined && version !== undefined)
tools.push({ name: tool, version });
tools.push({ name: BUILD_TOOL_NAMES.get(tool) ?? tool, version });
}
return tools;
};
@@ -0,0 +1,135 @@
import { describe, expect, it } from "vitest";
import {
RECORDED_LINUX_CORE_ARCHITECTURES,
RECORDED_LINUX_CORE_PROFILES,
} from "../../domain/native/recordedCrash.js";
import {
recordedCrashFixture,
recordedCrashFixtureBytes,
} from "../../../tests/fixtures/binaryDiagnostics/recordedCrash.js";
import { validateRecordedCrashSources } from "./RecordedCrashSourceBindings.js";
const diagnostics = { stdout: "", stderr: "", truncated: false } as const;
const bind = (
architecture?: "x86_64-little-endian" | "aarch64-little-endian",
) => {
const report = recordedCrashFixture("/selected.core", architecture);
return { report, bytes: recordedCrashFixtureBytes(report) };
};
describe("recorded core source bindings", () => {
it.each(RECORDED_LINUX_CORE_ARCHITECTURES)(
"binds each %s register to its own source bytes",
(architecture) => {
const { report, bytes } = bind(architecture);
expect(() =>
validateRecordedCrashSources(report, bytes, diagnostics),
).not.toThrow();
},
);
it("measures the AArch64 register window one slot wide per name", () => {
const profile = RECORDED_LINUX_CORE_PROFILES["aarch64-little-endian"];
expect(profile.registers.length).toBe(34);
expect(profile.prstatusBytes).toBe(392);
expect(profile.machine).toBe(183);
// The fixtures and the bindings both read these numbers, so they are pinned
// here against the ABI record rather than only used consistently.
expect(profile.registerOffset).toBe(112);
expect(profile.registerBytes).toBe(8);
const amd64 = RECORDED_LINUX_CORE_PROFILES["x86_64-little-endian"];
expect(amd64.registers.length).toBe(27);
expect(amd64.prstatusBytes).toBe(336);
expect(amd64.machine).toBe(62);
expect(amd64.registerOffset).toBe(112);
expect(amd64.registerBytes).toBe(8);
});
it("stamps each crafted core with the machine its profile declares", () => {
for (const architecture of RECORDED_LINUX_CORE_ARCHITECTURES) {
const bytes = bind(architecture).bytes;
expect(bytes.readUInt16LE(18)).toBe(
RECORDED_LINUX_CORE_PROFILES[architecture].machine,
);
}
});
it("rejects an AArch64 register bound to another slot", () => {
const { report, bytes } = bind("aarch64-little-endian");
const shifted = {
...report,
threads: report.threads.map((thread) => ({
...thread,
registers: thread.registers.map((register, index) =>
index === 3
? {
...register,
location: {
...register.location,
offset: `0x${(BigInt(register.location.offset) + 8n).toString(
16,
)}`,
},
}
: register,
),
})),
};
expect(() =>
validateRecordedCrashSources(shifted, bytes, diagnostics),
).toThrow(/aarch64-little-endian register identity/);
});
it("rejects an AArch64 note shorter than the profile descriptor", () => {
const { report, bytes } = bind("aarch64-little-endian");
const shortened = {
...report,
notes: report.notes.map((note, index) =>
index === 0
? {
...note,
descriptor_location: {
...note.descriptor_location,
bytes: "0x150",
},
}
: note,
),
};
expect(() =>
validateRecordedCrashSources(shortened, bytes, diagnostics),
).toThrow(/aarch64-little-endian source bytes/);
});
it("rejects an amd64 register name reported for AArch64", () => {
const { report, bytes } = bind("aarch64-little-endian");
const renamed = {
...report,
threads: report.threads.map((thread) => ({
...thread,
registers: thread.registers.map((register, index) =>
index === 0 ? { ...register, name: "rax" } : register,
),
})),
};
expect(() =>
validateRecordedCrashSources(renamed, bytes, diagnostics),
).toThrow(/aarch64-little-endian register identity/);
});
it("rejects a reported PID that differs from the recorded source bytes", () => {
const { report, bytes } = bind("aarch64-little-endian");
const wrongPid = {
...report,
threads: report.threads.map((thread) => ({
...thread,
historical_pid: thread.historical_pid + 1,
})),
};
expect(() =>
validateRecordedCrashSources(wrongPid, bytes, diagnostics),
).toThrow(/aarch64-little-endian source bytes/);
});
});
@@ -1,23 +1,26 @@
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
import {
RECORDED_LINUX_AMD64_REGISTERS,
RECORDED_LINUX_CORE_PROFILES,
type RecordedCrash,
} from "../../domain/native/recordedCrash.js";
const registerOffsets = new Map<string, number>(
RECORDED_LINUX_AMD64_REGISTERS.map((name, index) => [name, 112 + index * 8]),
);
/** Bind interpreted values and register identities to Linux amd64 source bytes. */
/** Bind interpreted values and register identities to recorded source bytes. */
export function validateRecordedCrashSources(
report: RecordedCrash,
snapshot: Buffer,
diagnostics: RecordedCrash["decoder_diagnostics"],
): void {
const profile = RECORDED_LINUX_CORE_PROFILES[report.architecture];
const registerOffsets = new Map<string, number>(
profile.registers.map((name, index) => [
name,
profile.registerOffset + index * profile.registerBytes,
]),
);
const fail = () => {
throw new AnalysisOutputError(
"inspect_recorded_crash",
"Reported PID or signal value differs from its recorded Linux amd64 source bytes.",
`Reported PID or signal value differs from its recorded ${report.architecture} source bytes.`,
{ capturedOutput: diagnostics },
);
};
@@ -29,10 +32,10 @@ export function validateRecordedCrashSources(
if (length < minimumBytes) return fail();
return snapshot.subarray(start, start + length);
};
// Fixed offsets independently checked against unchanged pwntools 4.15.0
// elf_prstatus_amd64 / elf_siginfo_64; this is the sole supported note ABI.
// Offsets come from unchanged pwntools 4.15.0 elf_prstatus_amd64 /
// elf_prstatus_aarch64 and elf_siginfo_64 through the shared domain profile.
for (const thread of report.threads) {
const bytes = descriptor(thread.note_index, 336);
const bytes = descriptor(thread.note_index, profile.prstatusBytes);
if (
thread.historical_pid !== bytes.readInt32LE(32) ||
thread.recorded_current_signal !== bytes.readInt16LE(12)
@@ -49,7 +52,7 @@ export function validateRecordedCrashSources(
)
throw new AnalysisOutputError(
"inspect_recorded_crash",
"Reported register source does not match its Linux amd64 register identity.",
`Reported register source does not match its ${report.architecture} register identity.`,
{ capturedOutput: diagnostics },
);
}
@@ -1,5 +1,8 @@
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
import type { RecordedCrash } from "../../domain/native/recordedCrash.js";
import {
RECORDED_LINUX_CORE_PROFILES,
type RecordedCrash,
} from "../../domain/native/recordedCrash.js";
// Exact amd64/core enum projections in the pinned pyelftools 0.33 profile.
const segmentKinds = new Map([
@@ -81,6 +84,8 @@ export function validateRecordedCrashStructure(
return snapshot.subarray(Number(offset), Number(offset) + bytes);
};
const header = bytesAt(0n, 64);
const expectedMachine =
RECORDED_LINUX_CORE_PROFILES[report.architecture].machine;
if (
!header
.subarray(0, 6)
@@ -90,7 +95,7 @@ export function validateRecordedCrashStructure(
![0, 3].includes(header[7] ?? -1) ||
header[8] !== 0 ||
header.readUInt16LE(16) !== 4 ||
header.readUInt16LE(18) !== 62
header.readUInt16LE(18) !== expectedMachine
)
fail();
const table = header.readBigUInt64LE(32);
+36
View File
@@ -1,3 +1,7 @@
import { compareWasmInterfaces } from "../application/wasm/WasmInterfaceComparisonService.js";
import { COMPARE_WASM_INTERFACES_CONTRACT } from "../contracts/wasm/wasmComparisonContracts.js";
import { resolvePairedEvidenceRequest } from "../application/EvidenceInputResolver.js";
import { recordSessionEvidenceSources } from "./sessionEvidence.js";
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import type { WasmArtifactService } from "../application/wasm/WasmArtifactService.js";
@@ -21,6 +25,38 @@ export const registerWasmTools = (
withAdmittedAnalysis?: WithAdmittedAnalysis,
evidenceById?: EvidenceLookup,
): void => {
const comparison = COMPARE_WASM_INTERFACES_CONTRACT;
const compareHandler = createEvidenceToolHandler(
server,
async (rawInput, options) => {
const input = comparison.inputSchema.safeParse(rawInput);
if (!input.success)
return err(
analysisInputErrorFromIssues(
comparison.name,
input.error.issues,
rawInput,
),
);
const sources = resolvePairedEvidenceRequest(input.data, evidenceById);
if (!sources.ok) return sources;
const result = await compareWasmInterfaces(sources.value, options);
if (!result.ok) return result;
const recorded = recordSessionEvidenceSources(recordEvidence, [
sources.value.left,
sources.value.right,
]);
return recorded.ok ? result : recorded;
},
logger,
recordEvidence,
withAdmittedAnalysis,
);
server.registerTool(
comparison.name,
toolRegistrationOptions(comparison),
compareHandler(comparison),
);
const contract = wasmArtifactModule.contract;
const handler = createEvidenceToolHandler(
server,
+8 -2
View File
@@ -362,8 +362,13 @@ export class WabtArtifactProvider implements WasmArtifactPort {
OPERATION,
"Unexpected WABT decoding output.",
);
const { imports, exports, customSectionNames, debugModuleNames } =
parseWabtWat(wat.stdout.text);
const {
imports,
exports,
customSectionNames,
debugModuleNames,
interface: interfaceFacts,
} = parseWabtWat(wat.stdout.text);
capturedOutput = {
stdout: dump.stdout.text,
stderr: dump.stderr.text,
@@ -388,6 +393,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
sections,
imports,
exports,
interface: interfaceFacts,
headers,
details,
wat: {
+116
View File
@@ -0,0 +1,116 @@
import { expect, it } from "vitest";
import { parseWabtWat } from "./WabtWat.js";
it("resolves imported index spaces and decoded escaped names without depending on body/type numbering", () => {
const report = parseWabtWat(`(module
(type (;0;) (func (param i32) (result i64)))
(import "a\\22b" "\\ef\\bb\\bf\\00" (func (;0;) (type 0)))
(func (;1;) (type 0) (param i32) (result i64) local.get 0 i64.extend_i32_s)
(export "" (func 1)))`).interface;
expect(report.entries).toMatchObject([
{
direction: "import",
module: 'a"b',
name: "\ufeff\0",
index: 0,
signature: { kind: "func", params: ["i32"], results: ["i64"] },
},
{
direction: "export",
name: "",
index: 1,
signature: { kind: "func", params: ["i32"], results: ["i64"] },
},
]);
});
it("retains exact memory/table/global/tag declarations and keeps unsupported references unknown", () => {
const report = parseWabtWat(`(module
(type (func (param i32)))
(type (struct (field i32)))
(import "env" "table" (table 1 3 funcref))
(memory i64 2 4 shared)
(global (mut i64) (i64.const 1))
(tag (type 0))
(func (type 0))
(func (param (ref null 1)))
(export "memory" (memory 0)) (export "global" (global 0))
(export "tag" (tag 0)) (export "gc" (func 1)))`).interface;
expect(report.entries.map((e) => e.signature)).toEqual([
{
kind: "table",
element: "funcref",
address_type: "i32",
minimum: "1",
maximum: "3",
shared: false,
},
{
kind: "memory",
page_size: "65536",
address_type: "i64",
minimum: "2",
maximum: "4",
shared: true,
},
{ kind: "global", value_type: "i64", mutable: true },
{ kind: "tag", params: ["i32"], results: [] },
null,
]);
expect(report.entries[4]?.issue).toBeTruthy();
});
it("does not bind recursive-type index spaces by guessing their cardinality", () => {
const report = parseWabtWat(
`(module (rec (type (struct)) (type (func))) (func (type 1)) (export "f" (func 0)))`,
).interface;
expect(report.entries).toMatchObject([
{ name: "f", signature: null, issue: expect.any(String) },
]);
});
it("preserves custom memory page sizes and treats unadmitted nested declarations as unknown", () => {
const custom = parseWabtWat(
'(module (memory 1 2 (pagesize 1)) (export "m" (memory 0)))',
).interface;
expect(custom.entries[0]?.signature).toMatchObject({
kind: "memory",
page_size: "1",
});
const unknown = parseWabtWat(
'(module (memory 1 (future-proposal 0)) (export "m" (memory 0)))',
).interface;
expect(unknown.entries[0]?.signature).toBeNull();
});
it("refuses typed name expansion within the source WABT byte cap before retaining interface output", () => {
const name = "x".repeat(6 * 1024 * 1024);
expect(
parseWabtWat(`(module (import "${name}" "f" (func)))`).interface,
).toMatchObject({
state: "partial",
entries: [],
issues: [expect.stringMatching(/representation budget/u)],
});
});
it("charges shared type signatures at every retained occurrence and preserves bounded partial facts", () => {
const params = Array(1000).fill("i32").join(" ");
const exports = Array.from(
{ length: 10000 },
(_, i) => `(export "f${i}" (func 0))`,
).join(" ");
const report = parseWabtWat(
`(module (type (func (param ${params}))) (import "e" "f" (func (type 0))) ${exports})`,
).interface;
expect(report.state).toBe("partial");
expect(report.entries.length).toBeGreaterThan(1);
expect(report.entries.length).toBeLessThan(10001);
expect(report.entries[0]?.signature).toMatchObject({
kind: "func",
params: expect.any(Array),
});
expect(Buffer.byteLength(JSON.stringify(report))).toBeLessThan(
32 * 1024 * 1024,
);
});
+325
View File
@@ -0,0 +1,325 @@
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import { wasmInterfaceEntryRepresentationBytes } from "../domain/wasm/wasmInterface.js";
import { WasmInterfaceBudget } from "./WasmInterfaceBudget.js";
import type {
WasmInterface,
WasmInterfaceEntry,
WasmInterfaceSignature,
WasmInterfaceValueType,
} from "../domain/wasm/wasmInterface.js";
import { watString } from "./WabtString.js";
/** Split direct WAT children only; function instruction bodies are never materialized. */
const parts = (form: string): string[] => {
const budget = new WasmInterfaceBudget();
const items: string[] = [];
let i = 1;
while (i < form.length - 1) {
if (/\s/u.test(form[i] ?? "")) {
i++;
continue;
}
if (form.startsWith("(;", i)) {
let depth = 1;
i += 2;
while (i < form.length && depth > 0) {
if (form.startsWith("(;", i)) {
depth++;
i += 2;
} else if (form.startsWith(";)", i)) {
depth--;
i += 2;
} else i++;
}
continue;
}
if (form.startsWith(";;", i)) {
const end = form.indexOf("\n", i);
i = end < 0 ? form.length : end + 1;
continue;
}
const start = i;
if (form[i] === '"') {
i = watString(form, i + 1).end;
} else if (form[i] === "(") {
let depth = 1;
i++;
while (i < form.length && depth > 0) {
if (form[i] === '"') i = watString(form, i + 1).end;
else {
if (form[i] === "(") depth++;
else if (form[i] === ")") depth--;
i++;
}
}
} else {
while (i < form.length && !/[\s()]/u.test(form[i] ?? "")) i++;
const atom = form.slice(start, i);
if (items[0] === "func" && items.length > 1 && !atom.startsWith("$"))
break;
}
if (i <= start) throw new Error("Malformed WABT form");
budget.reserve(64 + (i - start) * 2);
items.push(form.slice(start, i));
}
return items;
};
const head = (form: string): string | undefined => parts(form)[0];
const admittedValues = new Set([
"i32",
"i64",
"f32",
"f64",
"v128",
"funcref",
"externref",
]);
const isValueType = (type: string): type is WasmInterfaceValueType =>
admittedValues.has(type);
const values = (
form: string,
category: string,
): WasmInterfaceValueType[] | null => {
const result: WasmInterfaceValueType[] = [];
for (const item of parts(form)) {
if (!item.startsWith("(")) continue;
const group = parts(item);
if (group[0] !== category) continue;
for (const type of group.slice(1).filter((t) => !t.startsWith("$"))) {
if (!isValueType(type)) return null;
result.push(type);
}
}
return result;
};
const functionType = (
form: string,
kind: "func" | "tag",
): WasmInterfaceSignature | null => {
const params = values(form, "param");
const results = values(form, "result");
return params === null || results === null ? null : { kind, params, results };
};
const name = (text: string | undefined): string => {
if (text?.startsWith('"') !== true)
throw new Error("WABT interface name is missing");
return new TextDecoder("utf8", { fatal: true, ignoreBOM: true }).decode(
watString(text, 1).bytes,
);
};
const integer = (text: string): string | null => {
if (!/^(?:\d+|0x[\da-f]+)$/iu.test(text)) return null;
return BigInt(text).toString();
};
/** Resolve index spaces at the producer, not from retained raw WAT in consumers. */
const decodeWabtInterface = (
forms: readonly string[],
result: WasmInterface,
): WasmInterface => {
const budget = new WasmInterfaceBudget();
const types: (WasmInterfaceSignature | null)[] = [];
const typeNames = new Map<string, number>();
const definitions = new Map<
string,
{ signature: WasmInterfaceSignature | null; label: string | undefined }[]
>();
const typeSpaceUnknown = forms.some((f) => head(f) === "rec");
for (const form of forms) {
const p = parts(form);
if (p[0] !== "type") continue;
if (p[1]?.startsWith("$") === true) typeNames.set(p[1], types.length);
const body = p.find((item) => item.startsWith("("));
budget.reserve(256 + (body?.length ?? 0) * 6);
types.push(
body !== undefined && head(body) === "func" && !typeSpaceUnknown
? functionType(body, "func")
: null,
);
}
const signature = (form: string): WasmInterfaceSignature | null => {
const p = parts(form);
const kind = p[0];
if (kind === "func" || kind === "tag") {
const use = p.find((t) => t.startsWith("(type "));
if (use === undefined) return functionType(form, kind);
const reference = parts(use)[1];
const index =
reference === undefined
? undefined
: reference.startsWith("$")
? typeNames.get(reference)
: Number(reference);
const type =
index === undefined || typeSpaceUnknown ? undefined : types[index];
return type?.kind === "func" ? { ...type, kind } : null;
}
if (kind === "memory" || kind === "table") {
const groups = p.slice(1).filter((t) => t.startsWith("("));
let pageSize = "65536";
for (const group of groups) {
const declaration = parts(group);
if (
kind !== "memory" ||
declaration[0] !== "pagesize" ||
declaration.length !== 2
)
return null;
const parsedPageSize = integer(declaration[1] ?? "");
if (parsedPageSize === null) return null;
pageSize = parsedPageSize;
}
const tokens = p
.slice(1)
.filter((t) => !t.startsWith("$") && !t.startsWith("("));
const address_type = tokens[0] === "i64" ? "i64" : "i32";
if (tokens[0] === "i64" || tokens[0] === "i32") tokens.shift();
const minimum = integer(tokens.shift() ?? "");
const possibleMaximum =
tokens[0] === undefined ? null : integer(tokens[0]);
if (possibleMaximum !== null) tokens.shift();
const shared = tokens[0] === "shared";
if (shared) tokens.shift();
if (minimum === null) return null;
if (kind === "memory")
return tokens.length === 0
? {
kind,
page_size: pageSize,
address_type,
minimum,
maximum: possibleMaximum,
shared,
}
: null;
const element = tokens.shift();
return tokens.length === 0 &&
element !== undefined &&
(element === "funcref" || element === "externref")
? {
kind,
address_type,
minimum,
maximum: possibleMaximum,
shared,
element,
}
: null;
}
if (kind === "global") {
const declaration = p[1]?.startsWith("$") === true ? p[2] : p[1];
const mutable = declaration?.startsWith("(mut ") === true;
const value_type =
mutable && declaration !== undefined
? parts(declaration)[1]
: declaration;
return value_type !== undefined && isValueType(value_type)
? { kind, value_type, mutable }
: null;
}
return null;
};
const addDefinition = (
form: string,
): {
index: number;
kind: WasmInterfaceEntry["kind"];
signature: WasmInterfaceSignature | null;
} => {
const p = parts(form);
const rawKind = p[0] ?? "unknown";
const kind = ["func", "table", "memory", "global", "tag"].includes(rawKind)
? (rawKind as WasmInterfaceEntry["kind"])
: "unknown";
const space = definitions.get(kind) ?? [];
const item = {
signature: signature(form),
label: p[1]?.startsWith("$") === true ? p[1] : undefined,
};
budget.reserve(256);
const index = space.length;
space.push(item);
definitions.set(kind, space);
return { index, kind, signature: item.signature };
};
for (const form of forms) {
const p = parts(form);
if (p[0] !== "import") continue;
const body = p[3];
if (body === undefined) throw new Error("Incomplete WABT import");
const observed = addDefinition(body);
const entry: WasmInterfaceEntry = {
direction: "import",
module: name(p[1]),
name: name(p[2]),
...observed,
wat_form: form,
issue:
observed.signature === null
? "Import type is outside admitted core signature semantics"
: null,
};
budget.reserve(wasmInterfaceEntryRepresentationBytes(entry));
result.entries.push(entry);
}
for (const form of forms) {
const kind = head(form);
if (
kind !== undefined &&
["func", "table", "memory", "global", "tag"].includes(kind)
)
addDefinition(form);
}
for (const form of forms) {
const p = parts(form);
if (p[0] !== "export") continue;
const body = p[2];
if (body === undefined) throw new Error("Incomplete WABT export");
const descriptor = parts(body);
const rawKind = descriptor[0] ?? "unknown";
const kind = ["func", "table", "memory", "global", "tag"].includes(rawKind)
? (rawKind as WasmInterfaceEntry["kind"])
: "unknown";
const reference = descriptor[1];
const space = definitions.get(kind) ?? [];
const index =
reference === undefined
? -1
: reference.startsWith("$")
? space.findIndex((d) => d.label === reference)
: Number(reference);
const observed =
Number.isSafeInteger(index) && index >= 0 ? space[index] : undefined;
const entry: WasmInterfaceEntry = {
direction: "export",
module: null,
name: name(p[1]),
kind,
index: observed === undefined ? null : index,
signature: observed?.signature ?? null,
wat_form: form,
issue:
observed?.signature == null
? "Export type/index is outside admitted core signature semantics"
: null,
};
budget.reserve(wasmInterfaceEntryRepresentationBytes(entry));
result.entries.push(entry);
}
// Unsupported signatures leave inventory completeness intact. A missing
// signature is unknown per entry, not proof that a name is absent.
return result;
};
/** New typed coverage may be incomplete without discarding existing decoded WAT Evidence. */
export const parseWabtInterface = (forms: readonly string[]): WasmInterface => {
const result: WasmInterface = { state: "complete", entries: [], issues: [] };
try {
return decodeWabtInterface(forms, result);
} catch (cause: unknown) {
if (!(cause instanceof AnalysisResourceConstraintError)) throw cause;
result.state = "partial";
result.issues.push(cause.message);
return result;
}
};
+2 -2
View File
@@ -229,7 +229,7 @@ it("extracts escaped WAT import/export forms while retaining multiline producer
new URL("../../tests/fixtures/wasm/decoded.wat", import.meta.url),
"utf8",
);
expect(parseWabtWat(text)).toEqual({
expect(parseWabtWat(text)).toMatchObject({
imports: ['(import "env" "foo" (func (;0;) (type 0)))'],
exports: ['(export "memory" (memory 0))', '(export "run" (func 1))'],
customSectionNames: ["test"],
@@ -239,7 +239,7 @@ it("extracts escaped WAT import/export forms while retaining multiline producer
parseWabtWat(
'(module (import "a.b" "c\\0aline" (func)) (export "fake\\22Export[1]:" (func 0)))',
),
).toEqual({
).toMatchObject({
imports: ['(import "a.b" "c\\0aline" (func))'],
exports: ['(export "fake\\22Export[1]:" (func 0))'],
customSectionNames: [],
+46
View File
@@ -0,0 +1,46 @@
/** Decode a producer WAT string to frame objdump's raw display text. */
export const watString = (form: string, start: number) => {
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 { bytes: bytes.subarray(0, length), end: end + 1 };
};
+16 -47
View File
@@ -1,49 +1,5 @@
/** Decode a producer WAT string to frame objdump's raw display text. */
const watString = (form: string, start: number) => {
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 { bytes: bytes.subarray(0, length), end: end + 1 };
};
import { parseWabtInterface } from "./WabtInterface.js";
import { watString } from "./WabtString.js";
/** Read only module-name metadata from WABT's preserved name-section payload. */
const moduleNames = (bytes: Uint8Array): string[] => {
@@ -85,6 +41,7 @@ const moduleNames = (bytes: Uint8Array): string[] => {
export const parseWabtWat = (text: string) => {
if (!/^\(module(?:\s|\))/u.test(text))
throw new Error("Unsupported WABT WAT module prefix.");
const forms: string[] = [];
const imports: string[] = [];
const exports: string[] = [];
const customSectionNames: string[] = [];
@@ -140,6 +97,12 @@ export const parseWabtWat = (text: string) => {
} else if (char === ")") {
if (depth === 2) {
const form = text.slice(start, index + 1);
if (
/^\((?:type|rec|import|export|func|table|memory|global|tag)\s/u.test(
form,
)
)
forms.push(form);
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)) {
@@ -172,5 +135,11 @@ export const parseWabtWat = (text: string) => {
}
if (depth !== 0 || quoted || commentDepth !== 0)
throw new Error("Incomplete WABT WAT output.");
return { imports, exports, customSectionNames, debugModuleNames };
return {
imports,
exports,
customSectionNames,
debugModuleNames,
interface: parseWabtInterface(forms),
};
};
+17
View File
@@ -0,0 +1,17 @@
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import { WASM_INTERFACE_REPRESENTATION_BYTES } from "../domain/wasm/wasmInterface.js";
/** Eager typed observations share the existing WABT output capacity, not an agent token estimate. */
export class WasmInterfaceBudget {
#remaining = WASM_INTERFACE_REPRESENTATION_BYTES;
reserve(bytes: number): void {
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining)
throw new AnalysisResourceConstraintError(
"inspect_wasm_artifact",
"capacity",
"Typed WASM interface decoding exceeds the WABT output representation budget",
{ maximum_bytes: WASM_INTERFACE_REPRESENTATION_BYTES },
);
this.#remaining -= bytes;
}
}
@@ -0,0 +1,130 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect } from "vitest";
import { createEvidence, parseEvidence } from "../../src/domain/evidence.js";
import { wasmInterfaceComparisonSchema } from "../../src/contracts/wasm/wasmComparisonContracts.js";
import { composeBinarySession } from "../../src/application/binary/BinarySessionComposition.js";
import { AnalysisProviderRegistry } from "../../src/application/binary/AnalysisProviderRegistry.js";
import { createServer } from "../../src/server/createServer.js";
import { wasmArtifactFixture } from "../fixtures/wasm/artifact.js";
import { cliTest } from "../support/cli/cliFixture.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
cliTest(
"compares portable Evidence identically through CLI and retained MCP with schema/provenance",
async ({ cli }) => {
const sources = (["i32", "i64"] as const).map((parameter) => {
const report = wasmArtifactFixture();
report.interface = {
state: "complete",
issues: [],
entries: [
{
direction: "export",
module: null,
name: 'quoted"\ufeffname',
kind: "func",
index: 0,
signature: { kind: "func", params: [parameter], results: [] },
wat_form: '(export "quoted\\22\\ef\\bb\\bfname" (func 0))',
issue: null,
},
],
};
return createEvidence(
{
path: report.artifact.path,
sha256: report.artifact.sha256,
format: "file",
},
{ id: "typed-fixture", name: "Typed fixture", version: "1" },
{
operation: "inspect_wasm_artifact",
parameters: { path: report.artifact.path },
result: report,
confidence: "observed",
},
);
});
const [left, right] = sources;
if (left === undefined || right === undefined)
throw new Error("Missing paired fixture");
const root = await createTestTempDirectory("rea-wasm-comparison-");
const path = join(root, "input.json");
const input = { left, right, scope: "both" };
await writeFile(path, JSON.stringify(input));
const cliResult = await cli.run({
arguments: ["compare-wasm-interfaces", path, "--json"],
});
expect(cliResult.exitCode).toBe(0);
const cliEvidence = parseEvidence(cliResult.json);
const cliReport = wasmInterfaceComparisonSchema.parse(
cliEvidence.normalized_result,
);
expect(cliReport).toMatchObject({
coverage: "complete-within-inputs",
changes: [{ name: 'quoted"\ufeffname', state: "changed" }],
left: {
evidence_id: left.evidence_id,
artifact: {
path: "/artifacts/module.wasm",
sha256: "a".repeat(64),
bytes: 8,
},
},
});
const session = composeBinarySession(new AnalysisProviderRegistry([]));
const server = createServer({ kind: "session", session });
const client = new Client({ name: "wasm-interface-parity", version: "1" });
const [ct, st] = InMemoryTransport.createLinkedPair();
try {
await server.connect(st);
await client.connect(ct);
const tool = (await client.listTools()).tools.find(
(t) => t.name === "compare_wasm_interfaces",
);
if (tool?.outputSchema === undefined)
throw new Error("Missing comparison schema");
const outputSchema: Record<string, unknown> = tool.outputSchema;
const ajv = new Ajv2020({ strict: false, validateFormats: false });
expect(ajv.validate(tool.inputSchema, input)).toBe(true);
expect(ajv.validate(tool.inputSchema, { left, right })).toBe(false);
const mcp = await client.callTool({ name: tool.name, arguments: input });
expect(mcp.isError, JSON.stringify(mcp)).not.toBe(true);
expect(
ajv.validate(outputSchema, mcp.structuredContent),
JSON.stringify(ajv.errors),
).toBe(true);
const mcpEvidence = parseEvidence(mcp.structuredContent);
expect(mcpEvidence.normalized_result).toEqual(cliReport);
expect(mcpEvidence.evidence_id).toBe(cliEvidence.evidence_id);
expect(mcpEvidence.evidence_links).toEqual([
left.evidence_id,
right.evidence_id,
]);
const retained = await client.callTool({
name: tool.name,
arguments: {
scope: "both",
left: { kind: "retained-evidence", evidence_id: left.evidence_id },
right: { kind: "retained-evidence", evidence_id: right.evidence_id },
},
});
expect(retained.isError).not.toBe(true);
expect(parseEvidence(retained.structuredContent).evidence_id).toBe(
mcpEvidence.evidence_id,
);
const invalid = await client.callTool({
name: tool.name,
arguments: { ...input, scope: "all" },
});
expect(invalid.isError).toBe(true);
} finally {
await client.close();
await server.close();
await session.close();
}
},
);
@@ -4,6 +4,40 @@ import {
recordedCrashFixtureBytes,
} from "../../fixtures/binaryDiagnostics/recordedCrash.js";
import { validateRecordedCrashStructure } from "../../../src/native/pwntools/RecordedCrashStructureBindings.js";
import { RECORDED_LINUX_CORE_PROFILES } from "../../../src/domain/native/recordedCrash.js";
it.each(["x86_64-little-endian", "aarch64-little-endian"] as const)(
"admits a %s core whose header carries that profile's e_machine",
(architecture) => {
const report = recordedCrashFixture(undefined, architecture);
expect(() =>
validateRecordedCrashStructure(
report,
recordedCrashFixtureBytes(report),
report.decoder_diagnostics,
),
).not.toThrow();
},
);
it.each([
["x86_64-little-endian", "aarch64-little-endian"],
["aarch64-little-endian", "x86_64-little-endian"],
] as const)(
"rejects a %s report whose header carries the other profile's e_machine",
(reported, embedded) => {
const report = recordedCrashFixture(undefined, reported);
const snapshot = recordedCrashFixtureBytes(report);
snapshot.writeUInt16LE(RECORDED_LINUX_CORE_PROFILES[embedded].machine, 18);
expect(() =>
validateRecordedCrashStructure(
report,
snapshot,
report.decoder_diagnostics,
),
).toThrow(/Reported core structure differs/);
},
);
it.each([
[6, 0],
@@ -1,5 +1,8 @@
import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
import type { RecordedCrash } from "../../../src/domain/native/recordedCrash.js";
import type {
RecordedCrash,
RecordedCrashArchitecture,
} from "../../../src/domain/native/recordedCrash.js";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished } from "vitest";
@@ -48,6 +51,7 @@ it
.runIf(host)
.each([
"success",
"success-aarch64",
"wrong-register",
"wrong-register-source",
"missing-thread",
@@ -77,9 +81,13 @@ it
] as const)(
"validates recorded source bytes and cleanup: %s",
async (scenario) => {
const architecture = scenario.endsWith("aarch64")
? "aarch64-little-endian"
: "x86_64-little-endian";
const { path, value, bytes } = await fixture(
true,
scenario === "wrong-owner-display",
architecture,
);
if (scenario.startsWith("wrong-ei-version-"))
bytes[6] = Number(scenario.at(-1));
@@ -123,8 +131,9 @@ it
path,
include_debugger_context: false,
});
if (scenario === "success") {
if (scenario === "success" || scenario === "success-aarch64") {
if (!result.ok) throw result.error;
expect(result.value.architecture).toBe(architecture);
expect(result.value.threads).toEqual(value.threads);
expect(result.value.diagnostics.stdout).toBe("recorded warning");
} else
@@ -340,13 +349,17 @@ const expectOptionalContextFailure = (
});
};
const fixture = async (includeSignal = false, opaqueOwner = false) => {
const fixture = async (
includeSignal = false,
opaqueOwner = false,
architecture: RecordedCrashArchitecture = "x86_64-little-endian",
) => {
const workspace = await createTestWorkspace("rea-recorded-core-seam-");
onTestFinished(() => removeTestWorkspace(workspace.root));
const path = join(workspace.root, "recorded.core");
const value = includeSignal
? recordedCrashSignalFixture(path)
: recordedCrashFixture(path);
? recordedCrashSignalFixture(path, architecture)
: recordedCrashFixture(path, architecture);
if (opaqueOwner) {
value.notes = value.notes.map((note) =>
note.index === 0
@@ -1,4 +1,4 @@
import { chmod, realpath, writeFile } from "node:fs/promises";
import { chmod, readFile, realpath, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { systemDoctorHost } from "../../../src/application/Doctor.js";
@@ -78,7 +78,7 @@ describe("executable format refusal diagnostics", () => {
});
describe("target route diagnostics", () => {
it.each(["module.wasm", "module.bin", "misleading.js"])(
it.each(["module.wasm", "module.bin", "misleading.js", "misleading.hop"])(
"identifies a WASM v1 header in %s without suggesting native provider installation",
async (name) => {
const directory = await createTestTempDirectory("rea-wasm-target-");
@@ -346,3 +346,68 @@ describe("raw-binary load rejection diagnostics", () => {
});
});
});
describe("caller-selected and inferred target interpretation", () => {
it.each(["archive.hop", "archive.HOP"])(
"preserves inferred database routing for ZIP content named %s",
async (name) => {
const directory = await createTestTempDirectory("rea-zip-database-");
const bytes = Buffer.alloc(22);
bytes.writeUInt32LE(0x06054b50, 0);
const zipPath = join(directory, "archive.zip");
const databasePath = join(directory, name);
await writeFile(zipPath, bytes);
await writeFile(databasePath, bytes);
expect(await parseBinaryTarget(zipPath)).toMatchObject({
ok: true,
value: { kind: "archive", format: "zip" },
});
expect(await parseBinaryTarget(databasePath)).toMatchObject({
ok: true,
value: { kind: "database", format: "analysis-database" },
});
expect(await readFile(databasePath)).toEqual(bytes);
},
);
it("preserves explicit DOS COM interpretation for bytes with a WASM header", async () => {
const directory = await createTestTempDirectory("rea-wasm-explicit-com-");
const path = join(directory, "module.hop");
await writeFile(path, Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]));
expect(
await parseBinaryTarget(path, { formatHint: "dos-com" }),
).toMatchObject({
ok: true,
value: { kind: "executable", format: "dos-com", architecture: "x86" },
});
});
it.each(["module.wasm", "misleading.hop"])(
"preserves explicitly selected database routing for %s",
async (name) => {
const directory = await createTestTempDirectory(
"rea-wasm-explicit-database-",
);
const path = join(directory, name);
await writeFile(path, Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]));
expect(
await parseBinaryTarget(path, { targetKind: "database" }),
).toMatchObject({
ok: true,
value: { kind: "database", format: "analysis-database" },
});
},
);
it.each(["ordinary.hop", "ordinary.HOP"])(
"retains automatic database routing for non-WASM %s",
async (name) => {
const directory = await createTestTempDirectory("rea-ordinary-database-");
const path = join(directory, name);
await writeFile(path, "existing database content");
expect(await parseBinaryTarget(path)).toMatchObject({
ok: true,
value: { kind: "database", format: "analysis-database" },
});
},
);
});
+4 -9
View File
@@ -1,3 +1,4 @@
import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js";
import { API_SURFACE_EXAMPLE } from "../../../src/contracts/analysisViewApiSurfaceExample.js";
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
@@ -212,16 +213,10 @@ it("advertises exact schemas and projects one layout section from retained Evide
inspect: () => Promise.resolve(ok(layout)),
});
const { client, session } = await connect(service);
const advertised = (await client.listTools()).tools.find(
(tool) => tool.name === "inspect_analysis_view",
const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas(
client,
"inspect_analysis_view",
);
if (advertised?.outputSchema === undefined)
throw new Error("inspect_analysis_view must publish both schemas");
const ajv = new Ajv2020({ strict: false, validateFormats: false });
const inputSchema: Record<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, {
source: { kind: "inline", evidence: analysisViewLayoutEvidence() },
+15 -23
View File
@@ -1,9 +1,11 @@
import {
advertisedMcpToolSchemas,
assertRecordedMcpEvidence,
} from "../../fixtures/advertisedMcpTool.js";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it, onTestFinished } from "vitest";
import { BinaryLayoutService } from "../../../src/application/binaryDiagnostics/BinaryLayoutService.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { ok } from "../../../src/domain/result.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
@@ -43,16 +45,11 @@ it("advertises exact valid schemas and records inline layout without a binary ta
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
const advertised = (await client.listTools()).tools.find(
(tool) => tool.name === "inspect_binary_layout",
const schemas = await advertisedMcpToolSchemas(
client,
"inspect_binary_layout",
);
if (advertised?.outputSchema === undefined)
throw new Error("layout must publish both schemas");
const ajv = new Ajv2020({ strict: false, validateFormats: false });
const inputSchema: Record<string, unknown> = advertised.inputSchema;
const outputSchema: Record<string, unknown> = advertised.outputSchema;
expect(ajv.validateSchema(inputSchema)).toBe(true);
expect(ajv.validateSchema(outputSchema)).toBe(true);
const { ajv, inputSchema } = schemas;
expect(ajv.validate(inputSchema, { path: value.artifact.path })).toBe(true);
expect(
ajv.validate(inputSchema, {
@@ -64,19 +61,14 @@ it("advertises exact valid schemas and records inline layout without a binary ta
name: "inspect_binary_layout",
arguments: { path: value.artifact.path },
});
expect(response.isError).not.toBe(true);
expect(
ajv.validate(outputSchema, response.structuredContent),
JSON.stringify(ajv.errors),
).toBe(true);
const parsed = toolContract("inspect_binary_layout").outputSchema.parse(
response.structuredContent,
const evidence = assertRecordedMcpEvidence(
response,
"inspect_binary_layout",
schemas,
session,
value,
"observed",
);
const evidence = parseEvidence(parsed);
expect(evidence.confidence).toBe("observed");
expect(evidence.subject?.digest.sha256).toBe(value.artifact.sha256);
expect(parsed.normalized_result).toEqual(value);
expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
const viewed = await client.callTool({
name: "inspect_analysis_view",
arguments: {
+15 -21
View File
@@ -1,3 +1,7 @@
import {
advertisedMcpToolSchemas,
assertRecordedMcpEvidence,
} from "../../fixtures/advertisedMcpTool.js";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { createHash } from "node:crypto";
import { Ajv2020 } from "ajv/dist/2020.js";
@@ -66,16 +70,11 @@ it("advertises exact valid schemas and records inline EVM interface evidence wit
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
const advertised = (await client.listTools()).tools.find(
(tool) => tool.name === "inspect_evm_interface",
const schemas = await advertisedMcpToolSchemas(
client,
"inspect_evm_interface",
);
if (advertised?.outputSchema === undefined)
throw new Error("EVM interface inspection must publish both schemas");
const ajv = new Ajv2020({ strict: false, validateFormats: false });
const inputSchema: Record<string, unknown> = advertised.inputSchema;
const outputSchema: Record<string, unknown> = advertised.outputSchema;
expect(ajv.validateSchema(inputSchema)).toBe(true);
expect(ajv.validateSchema(outputSchema)).toBe(true);
const { ajv, inputSchema } = schemas;
expect(
ajv.validate(inputSchema, { path: value.artifact.path, encoding: "hex" }),
).toBe(true);
@@ -99,19 +98,14 @@ it("advertises exact valid schemas and records inline EVM interface evidence wit
name: "inspect_evm_interface",
arguments: { path: value.artifact.path, encoding: "hex" },
});
expect(response.isError).not.toBe(true);
expect(
ajv.validate(outputSchema, response.structuredContent),
JSON.stringify(ajv.errors),
).toBe(true);
const parsed = toolContract("inspect_evm_interface").outputSchema.parse(
response.structuredContent,
assertRecordedMcpEvidence(
response,
"inspect_evm_interface",
schemas,
session,
value,
"inferred",
);
const evidence = parseEvidence(parsed);
expect(evidence.confidence).toBe("inferred");
expect(evidence.subject?.digest.sha256).toBe(value.artifact.sha256);
expect(parsed.normalized_result).toEqual(value);
expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
expect(
(
await client.callTool({
@@ -217,6 +217,46 @@ function resolveReference(schema: unknown, reference: string): unknown {
return value;
}
function localReferenceExpansions(schema: Record<string, unknown>): number {
// Clients that render SDK declarations from the advertised input expand
// schema-local references against a shared per-input budget; Pi 1.1.0
// code-mode allows 32 and renders every later reference as `unknown`.
// Count conservatively: every resolvable, non-cyclic reference occurrence
// reached while walking the input, never the definitions themselves.
let count = 0;
const visit = (node: unknown, active: ReadonlySet<string>): void => {
if (Array.isArray(node)) {
node.forEach((child) => visit(child, active));
return;
}
if (!isRecord(node)) return;
if (typeof node.$ref === "string" && !active.has(node.$ref)) {
const target = resolveReference(schema, node.$ref);
if (target !== undefined) {
count += 1;
visit(target, new Set([...active, node.$ref]));
return;
}
}
for (const [key, value] of Object.entries(node)) {
if (
[
"$defs",
"definitions",
"examples",
"default",
"const",
"enum",
].includes(key)
)
continue;
visit(value, active);
}
};
visit(schema, new Set());
return count;
}
function recursiveReferences(tools: readonly ToolSchemas[]): string[] {
return tools.flatMap((tool) =>
(["inputSchema", "outputSchema"] as const).flatMap((kind) => {
@@ -674,6 +714,14 @@ it("ships nonrecursive input and output schemas in the generated catalog", () =>
expect(recursiveReferences(GENERATED_MCP_TOOL_CATALOG)).toEqual([]);
});
it("keeps advertised input references within the client expansion budget", () => {
for (const tool of GENERATED_MCP_TOOL_CATALOG)
expect(
localReferenceExpansions(tool.inputSchema),
`${tool.name} exhausts the shared reference-expansion budget`,
).toBeLessThanOrEqual(32);
});
it("rejects unresolved output-schema references even for uncalled tools", () => {
const errors = schemaErrors([
{
+97 -95
View File
@@ -1,5 +1,9 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import {
advertisedMcpToolSchemas,
assertRecordedMcpEvidence,
} from "../../fixtures/advertisedMcpTool.js";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it, onTestFinished, vi } from "vitest";
import { readFile, realpath, writeFile } from "node:fs/promises";
import { join } from "node:path";
@@ -20,20 +24,6 @@ import {
wasmArtifactExecution,
} from "../../fixtures/wasm/artifact.js";
const wasmToolSchemas = async (client: Client) => {
const advertised = (await client.listTools()).tools.find(
(tool) => tool.name === "inspect_wasm_artifact",
);
if (advertised?.outputSchema === undefined)
throw new Error("WASM recovery must name an advertised static tool");
const ajv = new Ajv2020({ strict: false, validateFormats: false });
const inputSchema: Record<string, unknown> = advertised.inputSchema;
const outputSchema: Record<string, unknown> = advertised.outputSchema;
expect(ajv.validateSchema(inputSchema)).toBe(true);
expect(ajv.validateSchema(outputSchema)).toBe(true);
return { name: advertised.name, ajv, inputSchema, outputSchema };
};
const assertFirstSummaryDelivery = async (
harness: Awaited<ReturnType<typeof createApplicationMcpHarness>>,
value: ReturnType<typeof wasmArtifactFixture>,
@@ -43,7 +33,7 @@ const assertFirstSummaryDelivery = async (
ajv: wasmAjv,
inputSchema,
outputSchema,
} = await wasmToolSchemas(harness.client);
} = await advertisedMcpToolSchemas(harness.client, "inspect_wasm_artifact");
const summaryInput = { path: value.artifact.path, detail: "summary" };
expect(wasmAjv.validate(inputSchema, summaryInput)).toBe(true);
expect(
@@ -91,73 +81,86 @@ const assertFirstSummaryDelivery = async (
return { summary, retained };
};
it("directs rejected native WASM opening to the advertised static tool while preserving missing-WABT diagnostics", async () => {
const directory = await createTestTempDirectory("rea-wasm-recovery-");
const path = join(
await realpath(directory),
process.platform === "win32" ? "module space.bin" : 'module "quoted".bin',
);
await writeFile(path, Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]));
const createClient = vi.fn(() => {
throw new Error("native provider must not start for WASM");
});
const session = createTestBinarySession(createClient);
const harness = await createApplicationMcpHarness({
session,
serverOptions: { environment: {} },
});
onTestFinished(harness.close);
const { client } = harness;
const { name, ajv, inputSchema } = await wasmToolSchemas(client);
const input = { path };
expect(
toolContract("inspect_wasm_artifact").inputSchema.safeParse(input).success,
).toBe(true);
const opening = await client.callTool({
name: "open_binary",
arguments: input,
});
expect(opening.isError).toBe(true);
const failure = parseMcpToolError(opening);
expect(failure).toMatchObject({
error: {
code: "target_unavailable",
category: "unsupported_target",
details: { constraint: "wasm_requires_static_inspection" },
},
});
expect(failure.error.remediation.action).toContain(JSON.stringify({ path }));
expect(ajv.validate(inputSchema, input)).toBe(true);
const inspection = await client.callTool({
name,
arguments: input,
});
expect(inspection.isError).toBe(true);
expect(parseMcpToolError(inspection)).toMatchObject({
error: {
code: "capability_unavailable",
details: {
operation: "inspect_wasm_artifact",
reason: expect.stringContaining("REA_WABT_BIN_DIRECTORY"),
it.each(["bin", "hop"])(
"directs rejected native WASM opening (%s suffix) to the advertised static tool while preserving missing-WABT diagnostics",
async (extension) => {
const directory = await createTestTempDirectory("rea-wasm-recovery-");
const path = join(
await realpath(directory),
process.platform === "win32"
? `module space.${extension}`
: `module "quoted".${extension}`,
);
await writeFile(path, Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]));
const createClient = vi.fn(() => {
throw new Error("native provider must not start for WASM");
});
const session = createTestBinarySession(createClient);
const harness = await createApplicationMcpHarness({
session,
serverOptions: { environment: {} },
});
onTestFinished(harness.close);
const { client } = harness;
const { name, ajv, inputSchema } = await advertisedMcpToolSchemas(
client,
"inspect_wasm_artifact",
);
const input = { path };
const opening = await client.callTool({
name: "open_binary",
arguments: input,
});
expect(opening.isError).toBe(true);
const failure = parseMcpToolError(opening);
expect(failure).toMatchObject({
error: {
code: "target_unavailable",
category: "unsupported_target",
details: { constraint: "wasm_requires_static_inspection" },
},
},
});
const summary = await client.callTool({
name,
arguments: { ...input, detail: "summary" },
});
expect(parseMcpToolError(summary)).toMatchObject({
error: {
code: "capability_unavailable",
details: { reason: expect.stringContaining("REA_WABT_BIN_DIRECTORY") },
},
});
await client.ping();
expect(createClient).not.toHaveBeenCalled();
expect(await readFile(path)).toEqual(
Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]),
);
});
});
const [, recoveryName, serializedInput] =
failure.error.remediation.action.match(
/call (\w+) with (\{.*\}) or run/,
) ?? [];
expect(recoveryName).toBe(name);
const recoveryInput = z
.object({ path: z.literal(path) })
.strict()
.parse(JSON.parse(serializedInput ?? "null"));
expect(ajv.validate(inputSchema, recoveryInput)).toBe(true);
const inspection = await client.callTool({
name: recoveryName ?? "",
arguments: recoveryInput,
});
expect(inspection.isError).toBe(true);
expect(parseMcpToolError(inspection)).toMatchObject({
error: {
code: "capability_unavailable",
details: {
operation: "inspect_wasm_artifact",
reason: expect.stringContaining("REA_WABT_BIN_DIRECTORY"),
},
},
});
const summary = await client.callTool({
name,
arguments: { ...input, detail: "summary" },
});
expect(parseMcpToolError(summary)).toMatchObject({
error: {
code: "capability_unavailable",
details: { reason: expect.stringContaining("REA_WABT_BIN_DIRECTORY") },
},
});
await client.ping();
expect(createClient).not.toHaveBeenCalled();
expect(await readFile(path)).toEqual(
Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]),
);
},
);
it("advertises exact valid schemas and records inline WASM interface evidence without a binary target", async () => {
const value = wasmArtifactFixture();
@@ -175,7 +178,11 @@ it("advertises exact valid schemas and records inline WASM interface evidence wi
});
onTestFinished(harness.close);
const { client } = harness;
const { ajv, inputSchema, outputSchema } = await wasmToolSchemas(client);
const schemas = await advertisedMcpToolSchemas(
client,
"inspect_wasm_artifact",
);
const { ajv, inputSchema } = schemas;
expect(ajv.validate(inputSchema, { path: value.artifact.path })).toBe(true);
expect(
ajv.validate(inputSchema, {
@@ -188,19 +195,14 @@ it("advertises exact valid schemas and records inline WASM interface evidence wi
name: "inspect_wasm_artifact",
arguments: { path: value.artifact.path },
});
expect(response.isError).not.toBe(true);
expect(
ajv.validate(outputSchema, response.structuredContent),
JSON.stringify(ajv.errors),
).toBe(true);
const parsed = toolContract("inspect_wasm_artifact").outputSchema.parse(
response.structuredContent,
assertRecordedMcpEvidence(
response,
"inspect_wasm_artifact",
schemas,
session,
value,
"observed",
);
const evidence = parseEvidence(parsed);
expect(evidence.confidence).toBe("observed");
expect(evidence.subject?.digest.sha256).toBe(value.artifact.sha256);
expect(parsed.normalized_result).toEqual(value);
expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
expect(
(
await client.callTool({
@@ -229,6 +229,62 @@ it("normalizes legacy version-minimum commands into build metadata", () => {
]);
});
it("names numeric build-version platforms and tools", () => {
const load = parseOtoolLoadCommands(
withOtoolMachHeader(
[
"Load command 0",
" cmd LC_BUILD_VERSION",
" cmdsize 48",
" platform 1",
" minos 11.0",
" sdk 26.5",
" ntools 2",
" tool 3",
" version 1267.0",
" tool 1029",
" version 1.0",
"Load command 1",
" cmd LC_BUILD_VERSION",
" cmdsize 24",
" platform 99",
" minos 1.0",
" sdk 1.0",
" ntools 0",
"Load command 2",
" cmd LC_BUILD_VERSION",
" cmdsize 32",
" platform MACCATALYST",
" minos 14.0",
" sdk 14.2",
" ntools 1",
" tool SWIFT",
" version 5.9",
"",
].join("\n"),
),
);
expect(load.builds).toEqual([
{
platform: "MACOS",
minimum_os: "11.0",
sdk: "26.5",
tools: [
{ name: "LD", version: "1267.0" },
{ name: "1029", version: "1.0" },
],
},
{ platform: "99", minimum_os: "1.0", sdk: "1.0", tools: [] },
{
platform: "MACCATALYST",
minimum_os: "14.0",
sdk: "14.2",
tools: [{ name: "SWIFT", version: "5.9" }],
},
]);
expect(load.commands[0]?.fields.platform).toBe(1);
});
it("does not classify LC_LOAD_DYLINKER as a dylib dependency", async () => {
const load = parseOtoolLoadCommands(
await nativeFixture("native-macho-header/otool-header-load.txt"),
@@ -273,3 +329,78 @@ it("limits thread-state entrypoints instead of reporting none silently", async (
"Thread-state entrypoints (LC_UNIXTHREAD/LC_THREAD) are retained as raw load commands; file offsets are not derived.",
);
});
it.each([
["1", "MACOS"],
["2", "IOS"],
["3", "TVOS"],
["4", "WATCHOS"],
["5", "BRIDGEOS"],
["6", "MACCATALYST"],
["7", "IOSSIMULATOR"],
["8", "TVOSSIMULATOR"],
["9", "WATCHOSSIMULATOR"],
["10", "DRIVERKIT"],
["11", "VISIONOS"],
["12", "VISIONOSSIMULATOR"],
["13", "FIRMWARE"],
["14", "SEPOS"],
["15", "MACOS_EXCLAVECORE"],
["16", "MACOS_EXCLAVEKIT"],
["17", "IOS_EXCLAVECORE"],
["18", "IOS_EXCLAVEKIT"],
["19", "TVOS_EXCLAVECORE"],
["20", "TVOS_EXCLAVEKIT"],
["21", "WATCHOS_EXCLAVECORE"],
["22", "WATCHOS_EXCLAVEKIT"],
["23", "VISIONOS_EXCLAVECORE"],
["24", "VISIONOS_EXCLAVEKIT"],
] as const)("names build platform %s as %s", (code, name) => {
const load = parseOtoolLoadCommands(
withOtoolMachHeader(
[
"Load command 0",
"cmd LC_BUILD_VERSION",
"cmdsize 24",
`platform ${code}`,
"minos 1.0",
"sdk 1.0",
"ntools 0",
"",
].join("\n"),
),
);
expect(load.builds[0]?.platform).toBe(name);
});
it.each([
["1", "CLANG"],
["2", "SWIFT"],
["3", "LD"],
["4", "LLD"],
["1024", "METAL"],
["1025", "AIRLLD"],
["1026", "AIRNT"],
["1027", "AIRNT_PLUGIN"],
["1028", "AIRPACK"],
["1031", "GPUARCHIVER"],
["1032", "METAL_FRAMEWORK"],
] as const)("names build tool %s as %s", (code, name) => {
const load = parseOtoolLoadCommands(
withOtoolMachHeader(
[
"Load command 0",
"cmd LC_BUILD_VERSION",
"cmdsize 32",
"platform 1",
"minos 1.0",
"sdk 1.0",
"ntools 1",
`tool ${code}`,
"version 1.0",
"",
].join("\n"),
),
);
expect(load.builds[0]?.tools).toEqual([{ name, version: "1.0" }]);
});
+52
View File
@@ -0,0 +1,52 @@
import type { Client } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect } from "vitest";
import { toolContract } from "../../src/contracts/toolContracts.js";
import { parseEvidence } from "../../src/domain/evidence.js";
import type { BinarySession } from "../../src/application/binary/BinarySession.js";
/** Verify the actual advertised schemas, retaining a private Ajv registry. */
export const advertisedMcpToolSchemas = async (
client: Client,
name: string,
) => {
const advertised = (await client.listTools()).tools.find(
(tool) => tool.name === name,
);
if (advertised?.outputSchema === undefined)
throw new Error(`${name} must publish both schemas`);
const ajv = new Ajv2020({ strict: false, validateFormats: false });
const inputSchema: Record<string, unknown> = advertised.inputSchema;
const outputSchema: Record<string, unknown> = advertised.outputSchema;
expect(ajv.validateSchema(inputSchema)).toBe(true);
expect(ajv.validateSchema(outputSchema)).toBe(true);
return { name: advertised.name, ajv, inputSchema, outputSchema };
};
/** Keep public output, provenance and session retention checks together. */
export const assertRecordedMcpEvidence = (
response: Awaited<ReturnType<Client["callTool"]>>,
name:
| "inspect_wasm_artifact"
| "inspect_binary_layout"
| "inspect_evm_interface",
schemas: Awaited<ReturnType<typeof advertisedMcpToolSchemas>>,
session: BinarySession,
expected: { readonly artifact: { readonly sha256: string } },
confidence: ReturnType<typeof parseEvidence>["confidence"],
) => {
expect(response.isError).not.toBe(true);
expect(
schemas.ajv.validate(schemas.outputSchema, response.structuredContent),
JSON.stringify(schemas.ajv.errors),
).toBe(true);
const parsed = toolContract(name).outputSchema.parse(
response.structuredContent,
);
const evidence = parseEvidence(parsed);
expect(evidence.confidence).toBe(confidence);
expect(evidence.subject?.digest.sha256).toBe(expected.artifact.sha256);
expect(parsed.normalized_result).toEqual(expected);
expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
return evidence;
};
+66 -49
View File
@@ -1,17 +1,41 @@
import type { RecordedCrash } from "../../../src/domain/native/recordedCrash.js";
import {
RECORDED_LINUX_CORE_PROFILES,
type RecordedCrash,
type RecordedCrashArchitecture,
} from "../../../src/domain/native/recordedCrash.js";
// One note is a 12-byte header, the "CORE\0" owner padded to 8 bytes, then the
// descriptor. The first note starts at the end of the owned header and phdr.
const NOTE_START = 0x80;
const OWNER_START = NOTE_START + 12;
const DESCRIPTOR_START = OWNER_START + 8;
const NOTE_HEADER_BYTES = 20;
const hex = (value: number): string => `0x${value.toString(16)}`;
/** Source-owned observation seam; these bytes do not simulate a verified ELF engine. */
export const recordedCrashFixture = (
path = "/artifacts/source-owned.core",
architecture: RecordedCrashArchitecture = "x86_64-little-endian",
): RecordedCrash => {
const descriptor = Buffer.alloc(336);
const profile = RECORDED_LINUX_CORE_PROFILES[architecture];
const noteBytes = NOTE_HEADER_BYTES + profile.prstatusBytes;
const descriptor = Buffer.alloc(profile.prstatusBytes);
descriptor.writeInt32LE(123, 32);
descriptor.writeInt16LE(11, 12);
descriptor.writeBigUInt64LE(0x1122334455667788n, 224);
// One distinguishable register carries a full uint64 so retention is asserted.
descriptor.writeBigUInt64LE(
0x1122334455667788n,
profile.registerOffset + 14 * profile.registerBytes,
);
return {
artifact: { path, sha256: "a".repeat(64), bytes: 512 },
artifact: {
path,
sha256: "a".repeat(64),
bytes: Math.max(512, DESCRIPTOR_START + profile.prstatusBytes),
},
format: "elf-core",
architecture: "x86_64-little-endian",
architecture,
target_execution: "not-performed",
live_process_identity: "unknown",
segments: [
@@ -20,7 +44,7 @@ export const recordedCrashFixture = (
type: "PT_NOTE",
header_location: { offset: "0x40", bytes: "0x38" },
offset: "0x80",
file_size: "0x164",
file_size: hex(noteBytes),
memory_size: "0x0",
virtual_address: "0x0",
physical_address: "0x0",
@@ -34,11 +58,14 @@ export const recordedCrashFixture = (
index: 0,
segment_index: 0,
type: "NT_PRSTATUS",
location: { offset: "0x80", bytes: "0x164" },
owner_location: { offset: "0x8c", bytes: "0x5" },
location: { offset: hex(NOTE_START), bytes: hex(noteBytes) },
owner_location: { offset: hex(OWNER_START), bytes: "0x5" },
owner_bytes_base64: Buffer.from("CORE\0").toString("base64"),
owner_display: "CORE",
descriptor_location: { offset: "0x94", bytes: "0x150" },
descriptor_location: {
offset: hex(DESCRIPTOR_START),
bytes: hex(profile.prstatusBytes),
},
descriptor_bytes_base64: descriptor.toString("base64"),
},
],
@@ -48,40 +75,16 @@ export const recordedCrashFixture = (
note_index: 0,
historical_pid: 123,
recorded_current_signal: 11,
registers: [
"r15",
"r14",
"r13",
"r12",
"rbp",
"rbx",
"r11",
"r10",
"r9",
"r8",
"rax",
"rcx",
"rdx",
"rsi",
"rdi",
"orig_rax",
"rip",
"cs",
"eflags",
"rsp",
"ss",
"fs_base",
"gs_base",
"ds",
"es",
"fs",
"gs",
].map((name, index) => ({
registers: profile.registers.map((name, index) => ({
name,
value: name === "rdi" ? "0x1122334455667788" : "0x0",
value: index === 14 ? "0x1122334455667788" : "0x0",
location: {
offset: `0x${(148 + 112 + index * 8).toString(16)}`,
bytes: "0x8",
offset: hex(
DESCRIPTOR_START +
profile.registerOffset +
index * profile.registerBytes,
),
bytes: hex(profile.registerBytes),
},
})),
},
@@ -100,27 +103,38 @@ export const recordedCrashFixture = (
};
/** Source-owned SIGSEGV descriptor for scalar/source binding regressions. */
export const recordedCrashSignalFixture = (path?: string): RecordedCrash => {
const value = recordedCrashFixture(path);
export const recordedCrashSignalFixture = (
path?: string,
architecture: RecordedCrashArchitecture = "x86_64-little-endian",
): RecordedCrash => {
const value = recordedCrashFixture(path, architecture);
const profile = RECORDED_LINUX_CORE_PROFILES[architecture];
const descriptor = Buffer.alloc(32);
descriptor.writeInt32LE(11, 0);
descriptor.writeInt32LE(-2, 4);
descriptor.writeInt32LE(1, 8);
descriptor.writeBigUInt64LE(0x1122334455667788n, 16);
value.artifact.bytes = 640;
const prstatusNoteBytes = NOTE_HEADER_BYTES + profile.prstatusBytes;
const signalNoteStart = NOTE_START + prstatusNoteBytes;
const signalDescriptorStart = signalNoteStart + NOTE_HEADER_BYTES;
const signalNoteBytes = NOTE_HEADER_BYTES + 32;
value.artifact.bytes = Math.max(640, signalDescriptorStart + 32);
value.segments = value.segments.map((segment) => ({
...segment,
file_size: "0x198",
file_size: hex(prstatusNoteBytes + signalNoteBytes),
}));
value.notes.push({
index: 1,
segment_index: 0,
type: "NT_SIGINFO",
location: { offset: "0x1e4", bytes: "0x34" },
owner_location: { offset: "0x1f0", bytes: "0x5" },
location: { offset: hex(signalNoteStart), bytes: hex(signalNoteBytes) },
owner_location: { offset: hex(signalNoteStart + 12), bytes: "0x5" },
owner_bytes_base64: Buffer.from("CORE\0").toString("base64"),
owner_display: "CORE",
descriptor_location: { offset: "0x1f8", bytes: "0x20" },
descriptor_location: {
offset: hex(signalDescriptorStart),
bytes: "0x20",
},
descriptor_bytes_base64: descriptor.toString("base64"),
});
value.signals.push({
@@ -141,7 +155,10 @@ export const recordedCrashFixtureBytes = (value: RecordedCrash): Buffer => {
bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]);
bytes.writeUInt32LE(1, 20);
bytes.writeUInt16LE(4, 16);
bytes.writeUInt16LE(62, 18);
bytes.writeUInt16LE(
RECORDED_LINUX_CORE_PROFILES[value.architecture].machine,
18,
);
bytes.writeBigUInt64LE(64n, 32);
bytes.writeUInt16LE(56, 54);
bytes.writeUInt16LE(value.segments.length, 56);