chore: merge current main into PR #2791

This commit is contained in:
morluto 2026-10-13 23:57:15 +08:00
commit 1f2e8499dc
177 changed files with 7940 additions and 1054 deletions

View File

@ -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/providers/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/guides/native/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`.

View File

@ -28,6 +28,7 @@ import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.Iterator;
@ -141,6 +142,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
"set_addresses_names",
"inspect_native_load_image",
"read_bytes",
"search_bytes",
"address_to_file_offset",
"ping",
"shutdown",
@ -378,6 +380,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
return switch (request.method) {
case "inspect_native_load_image" -> inspectNativeLoadImage(request.params);
case "read_bytes" -> readMemoryBytes(request.params);
case "search_bytes" -> searchMemoryBytes(request.params, descriptor);
case "address_to_file_offset" -> addressToFileOffset(request.params);
case "address_name" -> addressName(request.params);
case "list_documents" -> listDocuments(request.params);
@ -684,6 +687,160 @@ public final class ReaGhidraBridge extends HeadlessScript {
return result;
}
private JsonObject searchMemoryBytes(JsonObject params, SessionDescriptor descriptor) throws Exception {
// mask, start, end, and cursor are omitted when absent; JSON encoding drops those keys.
Set<String> keys = params.keySet();
if (!keys.containsAll(Set.of("document", "pattern", "limit")) ||
!Set.of("document", "pattern", "mask", "start", "end", "limit", "cursor").containsAll(keys))
throw new RequestFailure("invalid_request", "Bridge request fields are invalid");
requireDocument(params);
// Public input validation owns the grammar; validate again at the socket boundary.
String pattern = requireString(params, "pattern").replaceAll("[\\s\\p{Z}\\uFEFF]", "");
String mask = params.has("mask") ? requireString(params, "mask") : null;
if (mask != null) mask = mask.replaceAll("[\\s\\p{Z}\\uFEFF]", "");
if (pattern.isEmpty() || pattern.length() % 2 != 0 ||
(mask != null && mask.length() != pattern.length()))
throw new RequestFailure("invalid_request", "Mask length must equal a nonempty byte pattern length");
byte[] bytes = new byte[pattern.length() / 2];
byte[] masks = new byte[bytes.length];
boolean constrained = false;
for (int index = 0; index < bytes.length; index++) {
monitor.checkCancelled();
String pair = pattern.substring(index * 2, index * 2 + 2);
if ((!pair.equals("??") && !isHexadecimal(pair, 0, 2)) ||
(mask != null && !isHexadecimal(mask, index * 2, index * 2 + 2)))
throw new RequestFailure("invalid_request", "Use hexadecimal byte pairs or ?? wildcards");
int selected = mask == null ? 255 : Integer.parseInt(mask.substring(index * 2, index * 2 + 2), 16);
bytes[index] = (byte) (pair.equals("??") ? 0 : Integer.parseInt(pair, 16));
masks[index] = (byte) (pair.equals("??") ? 0 : selected);
constrained |= masks[index] != 0;
}
if (!constrained) throw new RequestFailure("invalid_request", "Pattern must constrain at least one bit");
int limit = requireBoundedInteger(params, "limit", 1, 1000);
Address start = params.has("start") ? requireAddress(params, "start") : null;
Address end = params.has("end") ? requireAddress(params, "end") : null;
if (start != null && end != null &&
(!start.getAddressSpace().equals(end.getAddressSpace()) || start.compareTo(end) > 0))
throw new RequestFailure("invalid_request", "Search bounds must be ordered in one address space");
JsonArray identity = new JsonArray();
for (String value : new String[] { descriptor.targetSha256, descriptor.profileDigest,
bytesHex(bytes), bytesHex(masks), start == null ? "" : canonicalAddress(start),
end == null ? "" : canonicalAddress(end), optionalString(params, "document") }) identity.add(value);
String binding = bytesHex(MessageDigest.getInstance("SHA-256").digest(identity.toString().getBytes(StandardCharsets.UTF_8)));
String cursor = params.has("cursor") ? requireString(params, "cursor") : null;
Address resume = null;
if (cursor != null) {
if (!cursor.startsWith(binding + ":"))
throw new RequestFailure("invalid_request", "Byte search cursor does not match the target and query");
resume = parseReaAddress(cursor.substring(binding.length() + 1));
MemoryBlock resumedBlock = currentProgram.getMemory().getBlock(resume);
Address bound = start == null ? end : start;
if (resumedBlock == null || !resumedBlock.isInitialized() ||
(bound != null && !bound.getAddressSpace().equals(resume.getAddressSpace())) ||
(start != null && resume.compareTo(start) < 0) ||
(end != null && resume.compareTo(end) > 0))
throw new RequestFailure("invalid_request", "Byte search cursor lies outside initialized query memory");
}
List<MemoryBlock> ordered = new ArrayList<>(List.of(currentProgram.getMemory().getBlocks()));
ordered.sort(Comparator.comparing(MemoryBlock::getStart));
JsonArray matches = new JsonArray();
JsonArray blocks = new JsonArray();
JsonArray skipped = new JsonArray();
JsonArray notLoaded = new JsonArray();
long covered = 0;
String next = null;
for (MemoryBlock block : ordered) {
monitor.checkCancelled();
Address low = block.getStart();
Address high = block.getEnd();
Address bound = start == null ? end : start;
if (bound != null && !bound.getAddressSpace().equals(low.getAddressSpace())) continue;
// Without bounds, search loaded memory only. Non-loaded file parts
// (such as .symtab) and overlays stay reachable through explicit bounds.
if (bound == null && (!block.isLoaded() || block.isOverlay())) {
notLoaded.add(byteSearchCoverage(block, low, high));
continue;
}
if (start != null && low.compareTo(start) < 0) low = start;
if (end != null && high.compareTo(end) > 0) high = end;
if (low.compareTo(high) > 0) continue;
if (!block.isInitialized()) {
skipped.add(byteSearchCoverage(block, low, high));
continue;
}
if (next != null) continue;
if (resume != null && low.compareTo(resume) < 0) low = resume;
if (low.compareTo(high) > 0 || !low.getAddressSpace().equals(high.getAddressSpace())) continue;
Address at = low;
Address examinedEnd = high;
while (at != null && at.compareTo(high) <= 0 && high.subtract(at) >= bytes.length - 1L) {
monitor.checkCancelled();
// Ghidra's end address bounds candidate starts, not the final matched byte.
Address found = currentProgram.getMemory().findBytes(at, high.subtract(bytes.length - 1L), bytes, masks, true, monitor);
monitor.checkCancelled();
if (found == null || high.subtract(found) < bytes.length - 1L) break;
if (matches.size() == limit) {
next = binding + ":" + canonicalAddress(found);
examinedEnd = found.addNoWrap(bytes.length - 1L);
break;
}
JsonObject match = new JsonObject();
match.addProperty("address", canonicalAddress(found));
match.addProperty("segment", block.getName());
Long offset = targetFileOffset(block, found).offset();
match.add("file_offset", offset == null ? JsonNull.INSTANCE : GSON.toJsonTree(offset));
Function function = currentProgram.getFunctionManager().getFunctionContaining(found);
JsonObject procedure = new JsonObject();
if (function != null) {
procedure.addProperty("address", canonicalAddress(function.getEntryPoint()));
procedure.addProperty("name", function.getName(true));
}
match.add("procedure", function == null ? JsonNull.INSTANCE : procedure);
matches.add(match);
at = found.next();
}
JsonObject coverage = byteSearchCoverage(block, low, examinedEnd);
covered = Math.addExact(covered, coverage.get("bytes").getAsLong());
blocks.add(coverage);
}
JsonObject searched = new JsonObject();
searched.add("blocks", blocks);
searched.addProperty("bytes", covered);
searched.add("skipped_uninitialized", skipped);
searched.add("skipped_not_loaded", notLoaded);
JsonObject result = new JsonObject();
result.add("matches", matches);
result.add("searched", searched);
result.addProperty("complete", next == null);
result.add("next_cursor", next == null ? JsonNull.INSTANCE : GSON.toJsonTree(next));
return result;
}
private static JsonObject byteSearchCoverage(MemoryBlock block, Address low, Address high) {
JsonObject result = new JsonObject();
result.addProperty("segment", block.getName());
result.addProperty("start", canonicalAddress(low));
result.addProperty("end", canonicalAddress(high));
result.addProperty("bytes", high.subtract(low) + 1);
return result;
}
private record TargetFileOffset(Long offset, int mappings) {}
private TargetFileOffset targetFileOffset(MemoryBlock block, Address address) throws Exception {
Long offset = null;
int mappings = 0;
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
if (!source.contains(address) || source.getFileBytes().isEmpty() ||
!isTargetSource(source.getFileBytes().get())) continue;
long candidate = source.getFileBytesOffset(address);
if (candidate < 0) continue;
offset = candidate;
mappings++;
}
return new TargetFileOffset(mappings == 1 && offset <= 9007199254740991L ? offset : null, mappings);
}
private JsonObject addressToFileOffset(JsonObject params) throws Exception {
requireKeys(params, Set.of("document", "address"));
requireDocument(params);
@ -691,18 +848,12 @@ public final class ReaGhidraBridge extends HeadlessScript {
MemoryBlock block = currentProgram.getMemory().getBlock(address);
if (block == null || !block.isInitialized())
throw new RequestFailure("invalid_request", "Address has no initialized file-backed memory: " + canonicalAddress(address));
Long fileOffset = null;
int mappings = 0;
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
if (!source.contains(address) || source.getFileBytes().isEmpty() ||
!isTargetSource(source.getFileBytes().get())) continue;
long offset = source.getFileBytesOffset(address);
if (offset < 0) continue;
fileOffset = offset;
mappings++;
}
if (mappings != 1)
throw new RequestFailure("invalid_request", "Address has " + mappings + " authoritative target-file mappings: " + canonicalAddress(address));
TargetFileOffset mapping = targetFileOffset(block, address);
if (mapping.mappings() != 1)
throw new RequestFailure("invalid_request", "Address has " + mapping.mappings() + " authoritative target-file mappings: " + canonicalAddress(address));
Long fileOffset = mapping.offset();
if (fileOffset == null)
throw new RequestFailure("invalid_request", "Address lacks a safe file offset: " + canonicalAddress(address));
JsonObject result = new JsonObject();
result.addProperty("address", canonicalAddress(address));
result.addProperty("file_offset", fileOffset);

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.",

View File

@ -458,7 +458,7 @@ artifact path. Its `expectedFirstTool` is `null`, and
`targetClarificationPassed` grades that limited routing outcome independently
of analysis prose heuristics. Analyzing a nearby example cannot pass this case.
The packaged desktop and parser-comparison scenarios now assess a closed set of
The packaged desktop, parser-comparison, and managed scenarios assess a closed set of
known fixture claims. Each final answer must be a strict JSON manifest containing
exactly the requested claim IDs, values, Evidence IDs, and Evidence authority and
confidence. Golden values are not included in the prompt. The evaluator checks
@ -475,15 +475,24 @@ analyses and use the requested module and export selectors. Boundary tests packa
the same source-owned desktop fixture and compare the same source-owned parser
files through actual MCP tool results, then verify that fabricated answers fail.
The managed rubric checks the exact public top-level type set and the CLI entry
point's declaring type and method name. The exact type-set claim requires an
unfiltered member inventory: `selection` must be null and the Evidence parameters
must omit `type_names`. A selected inventory can still ground the entry-point
claim. Both inspections must describe the freshly built DLL's SHA-256. Entry-point
resolution additionally requires complete artifact and member metadata, matching
non-null module MVIDs, and a unique method at the header's metadata token. These are
static metadata claims, not runtime behavior;
compiler-generated member counts, token values, and MVIDs are not golden answers.
Per-scenario `factualCorrectness` is `passed`, `failed`, or `not_assessed` within
the `configured_fixture_claims` scope. A pass establishes only the configured
claims, not unrestricted factual correctness. Native, managed, browser,
claims, not unrestricted factual correctness. Native, browser,
navigation-context, and address-context scenarios have no factual rubric and
remain `not_assessed`; the managed workflow requires both artifact and member
inspection to answer its type and entry-point question.
remain `not_assessed`.
The closed factual scenarios request complete producer Evidence on the initial
analysis so their source selectors can authenticate all configured facts.
inspection or analysis so their source selectors can authenticate all configured facts.
Summary/page/item workflows are exercised separately by `javascript-module-view`;
its heuristic gate does not establish full-producer factual correctness.

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`,

View File

@ -129,6 +129,18 @@ and Claude Code reports that autocompact is thrashing. To avoid this:
The sizes above come from REA's own `tools/list`. A live Claude Code session
against DeepSeek's API is unverified.
## Kimi Code
Setup registers `rea` in `~/.kimi-code/mcp.json`, or
`$KIMI_CODE_HOME/mcp.json` when set, while preserving unrelated settings and
creating a `.rea.backup` before updating an existing file. Kimi Code infers
stdio from the `command` entry, so setup does not add a `type` field. The
registration includes `startupTimeoutMs = 30000` and `toolTimeoutMs = 120000`.
Kimi Code also discovers personal skills in `~/.agents/skills` and project-local
`.agents/skills`, so no separate skill root is required; setup still installs
and verifies the matching skill files through the normal shared-skill flow.
Start a new Kimi Code session to pick up MCP and skill changes.
## Pi
For [Pi](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/mcp.md),

View File

@ -279,6 +279,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
| GitHub Copilot CLI | `copilot_cli` |
| Command Code | `commandcode` |
| Qwen Code | `qwen_code` |
| Kimi Code | `kimi` |
| VS Code | `vscode` |
| Grok Build | `grok_build` |
| OMP | `omp` |
@ -290,8 +291,8 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
| ZCode | `zcode` |
| DeepSeek Harness | `dsh` |
`dsh` is main-only until a release after 6.4.0. The registry check above does
not cover it.
`dsh` and `kimi` require a release containing them. The registry check above does
not cover them.
See [client configuration and troubleshooting](client-configuration.md) for
configuration paths, profiles, trust policies, and schema-limit workarounds.
@ -310,9 +311,12 @@ Hopper is separate commercial software with its own license. Its free demo has
vendor-defined limits, and a paid license is optional. REA reuses any detected
installation and preserves Hopper during uninstall.
The supported native host baseline is macOS 12+, Ubuntu 24.04+, Fedora 41+,
Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy. Ghidra and IDA have their own provider-specific
host requirements; Windows Ghidra uses the [experimental P0 boundary](../providers/windows-ghidra-p0.md).
The automated Hopper installer supports macOS 12+, Ubuntu 24.04+, Fedora 41+,
Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy. REA itself runs on any
Linux distribution with a recognized apt, dnf, or pacman package family, where
an existing Hopper launcher is selected through `HOPPER_LAUNCHER_PATH`. Ghidra
and IDA have their own provider-specific host requirements; Windows Ghidra uses
the [experimental P0 boundary](../providers/windows-ghidra-p0.md).
On macOS, approved setup downloads the official DMG, checks its published size
and digest, validates the application bundle, and atomically installs it to

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

View File

@ -549,3 +549,17 @@ Value projection preserves serialized node kinds, IDs, references and pagination
Exact and ambiguous value counts describe emitted root/object value instances;
a root and an object may contain the same value. Archive digests identify the
original bytes.
## Locate a byte signature
Use `search_bytes` after opening a target with Ghidra to locate a known constant
or instruction sequence. For example, `{"pattern":"48 8B ?? ?? 89","limit":100}`
returns ordered addresses with segments, source file offsets and containing
procedures. The CLI equivalent is
`rea search-bytes ./program '48 8B ?? ?? 89' --provider ghidra`.
Use `start`/`end` to constrain the inclusive address window, and continue with
`next_cursor` until `complete` is true. Inspect `searched` for page coverage and
skipped uninitialized blocks. A missing match does not establish absence from
uninitialized memory or across block boundaries. See
[Ghidra byte search](ghidra-provider.md#masked-byte-search) for mask and coverage
semantics. This query never executes or modifies target bytes.

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

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](../../development/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.

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.

View File

@ -198,3 +198,32 @@ It accepts the same architecture and DLL flags and verifies the selected PE role
in the analysis profile.
All lanes require matching native controls; package startup alone establishes
only package compatibility.
## Masked byte search
`search_bytes` (`rea search-bytes PATH '48 8B ?? ?? 89' --provider ghidra`)
searches initialized analyzed memory, including loader fixups. Supply hexadecimal
byte pairs, with `??` for any byte. An optional equal-length hexadecimal `mask`
selects bits: `pattern: "a0"`, `mask: "f0"` matches bytes from `a0` through `af`.
Wildcards remain unconstrained even with an explicit mask. At least one bit
must be constrained.
Optional `start` and `end` addresses are inclusive and select one address space.
A match must end at or before `end`, so `end` set inside a match excludes it.
Without bounds, loaded memory blocks are eligible, in Ghidra address order.
Blocks that are not loaded (file parts such as `.symtab`) and overlay blocks are
listed in `searched.skipped_not_loaded` and are not searched; pass `start` or
`end` in their address space to search them.
Overlapping matches are included; matches never cross block boundaries.
Each match reports its address, segment, unique source file offset (or null),
and containing procedure (or null).
Pages default to 100 matches and accept 1 to 1000. Pass `next_cursor` as `cursor`
with the same target, profile, pattern, mask and bounds to continue. `complete`
means no further matches remain. `searched.blocks` and `searched.bytes` describe
the inclusive memory ranges examined for this page, including lookahead for the
next match. Coverage can overlap between pages; do not sum it as unique memory.
`searched.skipped_uninitialized` lists eligible uninitialized blocks on every
page. These blocks are never searched. Cancellation returns a typed error,
discards the active Ghidra database, and does not return a successful partial page.
Hopper byte search remains unavailable until its implementation is verified.

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

View File

@ -65,7 +65,7 @@ unresolved relationship reads. Cancellation remains a tool error.
REA prepares complete MCP results within the pinned stdio client's 10 MiB
receive-buffer budget, including both text and structured representations and
room for the JSON-RPC envelope. Tool errors carry only their text
representation, so only that text counts against the budget. Response budget settings are captured at startup;
representation and `isError` flag, so only those count against the budget. Response budget settings are captured at startup;
restart or recreate the server to apply changes. If a result cannot fit, REA returns
`resource_constraint` with `details.resource: "transport"` before constructing
a document-sized string. Analysis Evidence remains complete in the current
@ -271,9 +271,11 @@ Evidence remain complete inline, and both input forms pass the same semantic,
identity, authority, and provenance checks without running the producer again.
References belong to the current connection's Evidence ledger. Opening another
target preserves retained records; `close_binary` clears them, even when no
binary is active. A fresh connection has its own ledger. A missing reference
reports its exact ID and `details.reason: "missing"`; the server cannot infer
target or restarting the current target preserves retained records, including
when provider cleanup fails; records remain available for export and retry.
`close_binary` clears them, even when no binary is active. A fresh connection
has its own ledger. A missing reference reports its exact ID and
`details.reason: "missing"`; the server cannot infer
whether it was never recorded, cleared, or retained by another connection.
Supply complete inline Evidence, repeat its producer, or import an exported
Evidence bundle before referencing that imported record. Export a bundle before
@ -602,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.
@ -650,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,
@ -660,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.

View File

@ -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.

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -1,4 +1,7 @@
harm_rank,location,trigger,current_copy,user_risk,proposed_replacement,implementation_status,verification_result
5,src/domain/native/bytePattern.ts,Unconstrained byte signature,Pattern must constrain at least one bit,Low risk; identifies input correction,Keep current copy,implemented,parser and MCP validation tests
5,src/domain/native/bytePattern.ts,Unequal mask length,Mask length must equal pattern length,Low risk; identifies input correction,Keep current copy,implemented,parser and MCP validation tests
5,bridge/ghidra/ReaGhidraBridge.java:searchMemoryBytes,Cursor reused with another query,Byte search cursor does not match the target and query,Low risk; identifies continuation mismatch,Keep current copy,implemented,real-provider verification required
1,src/domain/errors.ts:AnalysisCapabilityUnavailableError,Requested analysis unavailable,"Provider {providerId} cannot execute {operation}: {reason}",Exposes provider and internal operation/reason,"This analysis is unavailable for the current target. Choose another analysis or target.",implemented,focused assertions passed; full `npm run check` passed
2,src/domain/errors.ts:ProviderAdapterError,Provider adapter fails,"Provider {providerId} adapter failed during {operation}",Exposes provider and implementation boundary,"Analysis could not complete. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed.",implemented,focused assertions passed; full `npm run check` passed
3,src/domain/errors.ts:HopperRemoteError,Remote analysis request fails,"Hopper request failed ({code}): {safeMessage}",Exposes provider name code and upstream text,"Analysis could not complete. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed.",implemented,focused assertions passed; full `npm run check` passed

1 harm_rank location trigger current_copy user_risk proposed_replacement implementation_status verification_result
2 5 src/domain/native/bytePattern.ts Unconstrained byte signature Pattern must constrain at least one bit Low risk; identifies input correction Keep current copy implemented parser and MCP validation tests
3 5 src/domain/native/bytePattern.ts Unequal mask length Mask length must equal pattern length Low risk; identifies input correction Keep current copy implemented parser and MCP validation tests
4 5 bridge/ghidra/ReaGhidraBridge.java:searchMemoryBytes Cursor reused with another query Byte search cursor does not match the target and query Low risk; identifies continuation mismatch Keep current copy implemented real-provider verification required
5 1 src/domain/errors.ts:AnalysisCapabilityUnavailableError Requested analysis unavailable Provider {providerId} cannot execute {operation}: {reason} Exposes provider and internal operation/reason This analysis is unavailable for the current target. Choose another analysis or target. implemented focused assertions passed; full `npm run check` passed
6 2 src/domain/errors.ts:ProviderAdapterError Provider adapter fails Provider {providerId} adapter failed during {operation} Exposes provider and implementation boundary Analysis could not complete. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed. implemented focused assertions passed; full `npm run check` passed
7 3 src/domain/errors.ts:HopperRemoteError Remote analysis request fails Hopper request failed ({code}): {safeMessage} Exposes provider name code and upstream text Analysis could not complete. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed. implemented focused assertions passed; full `npm run check` passed

View File

@ -270,6 +270,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",

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,

View File

@ -1,4 +1,5 @@
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import {
chmod,
copyFile,
@ -259,7 +260,7 @@ try {
const execution = await runCodex(
answerInstructions.length === 0
? scenario.prompt
: `${scenario.prompt}\n\nThis closed factual rubric needs complete producer Evidence in the transcript. Request complete detail on each initial application analysis when supported; summary or view records alone are not accepted by this rubric.\n\n${answerInstructions}`,
: `${scenario.prompt}\n\nThis closed factual rubric needs complete producer Evidence in the transcript. Request complete detail on each initial inspection or analysis when supported; summary or view records alone are not accepted by this rubric.\n\n${answerInstructions}`,
scenario.approvedTools ?? [],
);
const transcriptDirectory = process.env.REA_AGENT_EVAL_TRANSCRIPT_DIR;
@ -440,6 +441,13 @@ async function createTargets(root, includeManaged) {
...fixtures,
largeJavascript,
managed: join(managedOutput, "AgentEval.dll"),
...(includeManaged
? {
managedSha256: createHash("sha256")
.update(await readFile(join(managedOutput, "AgentEval.dll")))
.digest("hex"),
}
: {}),
};
}

View File

@ -114,6 +114,22 @@ try {
length: bytes.length,
});
assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex"));
const signature = await call("search_bytes", {
pattern: "1e ?? 2f e1",
limit: 1000,
});
assert.equal(signature.normalized_result.complete, true);
assert.equal(signature.normalized_result.next_cursor, null);
assert.equal(signature.normalized_result.searched.bytes, bytes.length);
assert.deepEqual(
signature.normalized_result.matches.map((match) => match.address),
[entry],
);
assert.equal(signature.normalized_result.matches[0].file_offset, 0);
assert.equal(
signature.normalized_result.matches[0].procedure.address,
entry,
);
const procedures = await call("list_procedures");
assert.ok(
procedures.normalized_result.some(
@ -211,6 +227,69 @@ try {
return_42: true,
});
}
const repeatedPath = join(workspace, "signatures.bin");
const repeatedBytes = Buffer.from("b82a000000c3".repeat(1005), "hex");
const repeatedSha256 = createHash("sha256")
.update(repeatedBytes)
.digest("hex");
await writeFile(repeatedPath, repeatedBytes);
await call("open_binary", {
path: repeatedPath,
format: "raw-binary",
architecture: "x86_64",
entry_point: "0x1000",
});
// Import/analysis is complete before timing the first uncached search.
await call("read_bytes", { address: "0x1000", length: 6 }, repeatedSha256);
const started = performance.now();
const page = await call(
"search_bytes",
{ pattern: "b8 ?? 00 00 00 c3", limit: 1000 },
repeatedSha256,
);
const searchElapsedMs = performance.now() - started;
assert.equal(page.normalized_result.matches.length, 1000);
assert.equal(page.normalized_result.complete, false);
const rest = await call(
"search_bytes",
{
pattern: "b8 ?? 00 00 00 c3",
limit: 1000,
cursor: page.normalized_result.next_cursor,
},
repeatedSha256,
);
assert.equal(rest.normalized_result.matches.length, 5);
assert.equal(
rest.normalized_result.matches[0].address,
`0x${(0x1000 + 6000).toString(16)}`,
);
assert.equal(rest.normalized_result.complete, true);
const masked = await call(
"search_bytes",
{ pattern: "bf", mask: "f0", start: "0x1000", end: "0x1005" },
repeatedSha256,
);
assert.deepEqual(
masked.normalized_result.matches.map((match) => match.address),
["0x1000"],
);
const overlapping = await call(
"search_bytes",
{ pattern: "00 00", start: "0x1000", end: "0x1005" },
repeatedSha256,
);
assert.deepEqual(
overlapping.normalized_result.matches.map((match) => match.address),
["0x1002", "0x1003"],
);
const wrongCursor = await client.callTool({
name: "search_bytes",
arguments: { pattern: "c3", cursor: page.normalized_result.next_cursor },
});
assert.equal(wrongCursor.isError, true);
await call("close_binary");
assert.deepEqual(await readFile(repeatedPath), repeatedBytes);
report = {
ok: true,
mocked: false,
@ -231,6 +310,15 @@ try {
load_images_verified: true,
original_unchanged: true,
x86_targets: x86Targets,
byte_search: {
matches: 1005,
page_size: 1000,
first_search_elapsed_ms: searchElapsedMs,
timing_scope: "MCP request after import; first uncached search",
token_usage: "unmeasured",
process_family_cpu_time: "unmeasured",
peak_process_family_memory: "unmeasured",
},
};
} catch (error) {
if (stderr.trim()) process.stderr.write(stderr);

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,

View File

@ -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 });
}

View File

@ -89,6 +89,30 @@ check(
["src/domain/javascript/javascriptSemanticTraversal.ts"],
);
// A producer that pins both the soft and hard ceiling of every resource must take
// the vector from one owner. Audited 2026-10-13 against the other prlimit lanes:
// src/pcap/PcapNativeBound.ts pairs soft and hard but budgets 300 CPU seconds and
// has no resident-input ceiling; src/evm/EvmoleInterfaceProvider.ts,
// src/firmware/FirmwareCommand.ts and src/javascript/recovery/WakaruCommand.ts
// spell soft-only forms (`--core=0:`, `--core=0`). Those four stay as they are;
// a new paired producer calls prlimitArgumentVector instead of spelling options.
check(
"inline paired prlimit ceilings (use prlimitArgumentVector from src/process/LinuxResourceLimits)",
files,
/--core=0:0/,
["src/process/LinuxResourceLimits.ts", "src/pcap/PcapNativeBound.ts"],
);
// Census is spelling-shaped, like the rules above: a lane that expresses the same
// ceilings differently (`1024 ** 3`, camelCase fields) is not caught here, which is
// why the four lanes above are listed rather than left to a future reader.
check(
"inline resident-input budget (use BOUNDED_PRODUCER_LIMITS from src/process/LinuxResourceLimits)",
files,
/input_bytes:\s*32 \* 1024 \* 1024/,
["src/process/LinuxResourceLimits.ts"],
);
// Fixed-arity backtick keys audited 2026-10-10: every prefix field is NUL-free
// (hex digests, file paths, enums, numbers, or compositeKey JSON which escapes
// NUL), with free-form source strings only in final position. New backtick-\0

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({

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(

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)),
};
};

View File

@ -0,0 +1,34 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { resolveAppBundleExecutable } from "./AppBundleExecutable.js";
describe("app bundle Info.plist magic", () => {
it("does not decode a high-bit fold of bplist as a binary Info.plist", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-bplist-fold-"));
try {
const bundle = join(root, "Fixture.app");
const plistPath = join(bundle, "Contents", "Info.plist");
await mkdir(join(bundle, "Contents"), { recursive: true });
const plist = Buffer.from("bplist00");
plist[0] = (plist[0] ?? 0) | 0x80;
await writeFile(plistPath, plist);
let decoded = false;
const result = await resolveAppBundleExecutable(bundle, {
readFile: async () => plist,
realpath: async (path) => path,
decodeBinaryPlist: async () => {
decoded = true;
return "Fixture";
},
});
expect(decoded).toBe(false);
expect(result.ok).toBe(false);
} finally {
await rm(root, { recursive: true, force: true });
}
});
});

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,

View File

@ -110,9 +110,12 @@ const parseDocument = (
return requireObject(parseToml(text), "The configuration root");
// Pi loads `mcp.json` with a strict `JSON.parse` and no BOM tolerance, so
// comments, trailing commas, a BOM, or an empty existing file must be
// rejected instead of registering into a document Pi cannot load.
if (format === "pi")
// rejected instead of registering into a document the client cannot load.
// Kimi also reads mcp.json with JSON.parse and an empty-file fallback.
if (format === "pi" || format === "kimi") {
if (format === "kimi" && text.trim().length === 0) return {};
return requireObject(JSON.parse(text), "The configuration root");
}
// An empty file, which some clients create before any server is added,
// holds no settings to preserve.
if (isEmptyClientConfigurationText(text)) return {};
@ -1446,6 +1449,13 @@ export const clientRegistrationEntry = (
? {}
: { environment: { ...environment } }),
};
case "kimi":
// Kimi infers stdio from command; it uses `transport`, not `type`.
return {
command: executable,
args,
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
};
case "opencode_v2":
// V2 connects servers unless `disabled` is true.
return {

View File

@ -66,6 +66,8 @@ const registrationSchema = z
command: z.string().min(1),
args: z.array(z.string()).default([]),
startup_timeout_sec: z.number().positive().optional(),
startupTimeoutMs: z.number().positive().optional(),
toolTimeoutMs: z.number().positive().optional(),
type: z.string().optional(),
transport: z.string().optional(),
serverName: z.string().optional(),
@ -258,6 +260,21 @@ const registrationAligned = (
MCP_STARTUP_POLICY.codexStartupTimeoutSeconds
)
return false;
if (
client.name === "kimi" &&
(registration.startupTimeoutMs !==
MCP_STARTUP_POLICY.kimiStartupTimeoutMs ||
registration.toolTimeoutMs !== MCP_STARTUP_POLICY.kimiToolTimeoutMs)
)
return false;
if (
(client.format === "kimi" ||
client.format === "omp" ||
client.format === "pi") &&
registration.type !== undefined &&
registration.type !== "stdio"
)
return false;
if (client.format === "vscode" && registration.type !== "stdio") return false;
// A Harness registration only connects as the rea stdio server; another
// server name or transport leaves the session without REA tools.
@ -266,13 +283,6 @@ const registrationAligned = (
(registration.serverName !== "rea" || registration.transport !== "stdio")
)
return false;
// OMP and Pi infer stdio for a command entry without an explicit type.
if (
(client.format === "omp" || client.format === "pi") &&
registration.type !== undefined &&
registration.type !== "stdio"
)
return false;
if (
client.format === "copilot_cli" &&
(registration.type !== "stdio" ||
@ -331,6 +341,12 @@ const parseRegistration = (
});
}
const registration = registrationSchema.parse(value);
if (
client.format === "kimi" &&
registration.transport !== undefined &&
registration.transport !== "stdio"
)
throw new TypeError("Expected an stdio registration");
if (
(client.format === "vscode" || client.format === "copilot_cli") &&
registration.type !== "stdio"
@ -339,7 +355,9 @@ const parseRegistration = (
if (client.format === "commandcode" && registration.transport !== "stdio")
throw new TypeError("Expected an stdio registration");
if (
(client.format === "omp" || client.format === "pi") &&
(client.format === "kimi" ||
client.format === "omp" ||
client.format === "pi") &&
registration.type !== undefined &&
registration.type !== "stdio"
)

View File

@ -60,6 +60,7 @@ type DirectAnalysisTool =
| "annotate_native_function"
| "inspect_native_load_image"
| "read_bytes"
| "search_bytes"
| "address_to_file_offset"
| "binary_overview"
| "procedure_pseudo_code"

View File

@ -74,7 +74,7 @@ describe("doctor", () => {
),
).toEqual({
node: `Install ${SUPPORTED_NODE_VERSION_PROSE}.`,
host: "REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, Omarchy, and the experimental Windows x64 Ghidra P0 boundary.",
host: "REA supports macOS 12+, Linux distributions with a recognized apt, dnf, or pacman package family, and the experimental Windows x64 Ghidra P0 boundary; automated Hopper setup additionally requires Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy.",
hopper: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",
target: "Supply a readable local app or program path.",
});
@ -418,10 +418,9 @@ describe("doctor scoped readiness", () => {
platform: "linux",
linuxDistribution: () =>
Promise.resolve({
id: "debian",
versionId: "13",
packageFamily: "deb",
supported: false,
id: "nixos",
packageFamily: undefined,
hopperSetupSupported: false,
}),
clientRegistrations: () =>
Promise.resolve([
@ -451,6 +450,31 @@ describe("doctor scoped readiness", () => {
);
});
it("accepts a Debian-family host that has no tested Hopper package", async () => {
const result = await runDoctor(
undefined,
host({
platform: "linux",
macosVersion: () => Promise.resolve(undefined),
linuxDistribution: () =>
Promise.resolve({
id: "kali",
versionId: "2026.3",
packageFamily: "deb",
hopperSetupSupported: false,
}),
}),
);
expect(result.checks).toContainEqual(
expect.objectContaining({
name: "host",
ok: true,
detail: "kali 2026.3",
}),
);
});
it("requires explicitly selected providers instead of any available provider", async () => {
const ghidra = {
id: "ghidra",
@ -502,7 +526,7 @@ describe("doctor Linux and Hopper discovery", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
hopperSetupSupported: true,
}),
configuredHopperPath: path,
executable: (candidate) => Promise.resolve(candidate === path),
@ -522,7 +546,7 @@ describe("doctor Linux and Hopper discovery", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
hopperSetupSupported: true,
}),
linuxDemoRuntimeCheck: () =>
Promise.resolve({
@ -554,7 +578,7 @@ describe("doctor Linux and Hopper discovery", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
hopperSetupSupported: true,
}),
executable: (candidate) => Promise.resolve(candidate === path),
supportedLinuxHopper: () => Promise.resolve(false),

View File

@ -2,7 +2,7 @@ import {
SUPPORTED_NODE_VERSION_PROSE,
supportsNodeVersion,
} from "../domain/runtimeVersion.js";
import { linuxHopperLauncherPath } from "./LinuxHopper.js";
import { linuxHopperLauncherPath, supportsReaHost } from "./LinuxHopper.js";
import type {
DoctorCheck,
DoctorHost,
@ -129,7 +129,7 @@ const hostCheck = async (host: DoctorHost): Promise<DoctorCheck> => {
host.platform === "linux" ? await host.linuxDistribution() : undefined;
const supported =
(host.platform === "darwin" && parseMajor(macosVersion) >= 12) ||
(host.platform === "linux" && linuxDistribution?.supported === true) ||
(host.platform === "linux" && supportsReaHost(linuxDistribution)) ||
(host.platform === "win32" && host.architecture === "x64");
const detail =
macosVersion ??
@ -138,7 +138,7 @@ const hostCheck = async (host: DoctorHost): Promise<DoctorCheck> => {
: `${linuxDistribution.id} ${linuxDistribution.versionId ?? "unknown"}`);
return check("host", supported, detail, {
remediation:
"REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, Omarchy, and the experimental Windows x64 Ghidra P0 boundary.",
"REA supports macOS 12+, Linux distributions with a recognized apt, dnf, or pacman package family, and the experimental Windows x64 Ghidra P0 boundary; automated Hopper setup additionally requires Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy.",
classification: "unsupported_host",
});
};

View File

@ -8,6 +8,7 @@ import { CordisExpression, DshCordisYaml } from "./DshCordisYaml.js";
export const DSH_ENTRY_ID = "rea-mcp";
export const DSH_CLIENT_PACKAGE = "@deepseek-ai/dsh-mcp-client";
const LAUNCHER_KEYS = new Set(["serverName", "transport", "command", "args"]);
const SELECTOR_KEYS = new Set(["id", "name"]);
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" &&
value !== null &&
@ -295,10 +296,15 @@ export class DshCordisDocument {
carried = true;
return [this.#entry(desired, invalidated)] as typeof inserted.items;
});
replacements.set(
op.source,
inserted.items.length === 0 ? undefined : copy,
);
if (inserted.items.length === 0) copy.delete("insert");
// An emptied operation keeps caller fields unless it would patch REA's row.
const kept =
inserted.items.length > 0 ||
(this.#id(op.map) !== DSH_ENTRY_ID &&
copy.items.some(
(pair) => !SELECTOR_KEYS.has(this.#yaml.value(pair.key) as string),
));
replacements.set(op.source, kept ? copy : undefined);
}
const output = this.#yaml.document.clone();
const outputRoot = output.contents;

View File

@ -7,6 +7,7 @@ import {
linuxPackageManagerCommands,
linuxSharedLibrariesAvailable,
parseLinuxDistribution,
supportsReaHost,
type LinuxDistribution,
type LinuxHopperDownload,
type LinuxHopperInstallHost,
@ -19,7 +20,7 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
hopperSetupSupported: true,
};
archive = new Uint8Array([1, 2, 3]);
archiveOk = true;
@ -66,7 +67,7 @@ describe("Linux Hopper host classification", () => {
(document, family) => {
expect(parseLinuxDistribution(document)).toMatchObject({
packageFamily: family,
supported: true,
hopperSetupSupported: true,
});
},
);
@ -99,8 +100,32 @@ describe("Linux Hopper host classification", () => {
"ID=garuda\nID_LIKE=arch\n",
"ID=omarchy\n",
"ID=omarchy\nID_LIKE=debian\n",
])("rejects unsupported vendor/version combinations", (document) => {
expect(parseLinuxDistribution(document).supported).toBe(false);
])(
"rejects vendor/version combinations without a tested Hopper installer",
(document) => {
expect(parseLinuxDistribution(document).hopperSetupSupported).toBe(false);
},
);
it.each([
'ID=kali\nVERSION_ID="2026.3"\nID_LIKE=debian\n',
'ID=debian\nVERSION_ID="13"\nID_LIKE=debian\n',
'ID=linuxmint\nVERSION_ID="22.1"\nID_LIKE="ubuntu debian"\n',
"ID=manjaro\nID_LIKE=arch\n",
"ID=derivative\nID_LIKE='debian'\n",
"ID='debian'\nVERSION_ID='13'\n",
])(
"hosts REA on a distribution Hopper has no tested package for: %s",
(document) => {
const distribution = parseLinuxDistribution(document);
expect(distribution.hopperSetupSupported).toBe(false);
expect(supportsReaHost(distribution)).toBe(true);
},
);
it("withholds host support from a distribution with no package family", () => {
expect(supportsReaHost(parseLinuxDistribution("ID=nixos\n"))).toBe(false);
expect(supportsReaHost(undefined)).toBe(false);
});
});
@ -166,7 +191,7 @@ describe("Linux Hopper installation", () => {
host.distributionValue = {
id: family,
packageFamily: family,
supported: true,
hopperSetupSupported: true,
};
const result = await installFixture(host);
expect(result).toEqual({
@ -194,7 +219,7 @@ describe("Linux Hopper installation", () => {
host.distributionValue = {
id: "debian",
packageFamily: "deb",
supported: false,
hopperSetupSupported: false,
};
if (scenario === "package request failure") host.archiveOk = false;
if (scenario === "checksum mismatch") host.archive = new Uint8Array([9]);

View File

@ -76,19 +76,35 @@ interface LinuxDistributionIdentity {
readonly versionId?: string;
}
/** Parsed Linux distribution information used for support and package selection. */
/**
* Parsed Linux distribution information used for package selection.
*
* `hopperSetupSupported` is the automated Hopper installer's tested list. It is
* narrower than the hosts where REA itself runs, which {@link supportsReaHost}
* answers from the package family.
*/
export type LinuxDistribution = LinuxDistributionIdentity &
(
| {
readonly packageFamily: LinuxPackageFamily;
readonly supported: true;
readonly hopperSetupSupported: true;
}
| {
readonly packageFamily: LinuxPackageFamily | undefined;
readonly supported: false;
readonly hopperSetupSupported: false;
}
);
/**
* Whether REA's analysis providers run on this distribution. A recognized
* package family means a glibc host with a known package layout, which is what
* the providers need; downloading and installing the official Hopper package
* additionally requires a distribution on its tested list.
*/
export const supportsReaHost = (
distribution: LinuxDistribution | undefined,
): boolean => distribution?.packageFamily !== undefined;
/** Download response kept independent of Node's global fetch implementation. */
export interface LinuxHopperDownload {
readonly ok: boolean;
@ -141,16 +157,19 @@ export const parseLinuxDistribution = (text: string): LinuxDistribution => {
const key = match[1];
const encoded = match[2];
if (key === undefined || encoded === undefined) continue;
const value =
encoded.startsWith('"') && encoded.endsWith('"')
? encoded.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\")
: encoded;
const quote = encoded[0];
const quoted = (quote === "'" || quote === '"') && encoded.endsWith(quote);
const value = !quoted
? encoded
: quote === "'"
? encoded.slice(1, -1)
: encoded.slice(1, -1).replace(/\\(["\\$`])/gu, "$1");
fields.set(key, value);
}
const id = (fields.get("ID") ?? "unknown").toLowerCase();
const versionId = fields.get("VERSION_ID");
const packageFamily = packageFamilyFor(id, fields.get("ID_LIKE"));
const supported =
const hopperSetupSupported =
packageFamily !== undefined &&
(id === "arch" ||
id === "cachyos" ||
@ -162,9 +181,9 @@ export const parseLinuxDistribution = (text: string): LinuxDistribution => {
id,
...(versionId === undefined ? {} : { versionId }),
};
return supported
? { ...identity, packageFamily, supported: true }
: { ...identity, packageFamily, supported: false };
return hopperSetupSupported
? { ...identity, packageFamily, hopperSetupSupported: true }
: { ...identity, packageFamily, hopperSetupSupported: false };
};
/** Read and classify the current Linux distribution. */
@ -193,7 +212,7 @@ export const installLinuxHopper = async (
if (options.signal?.aborted === true)
return { status: "failed", reason: "cancelled" };
const distribution = await host.distribution();
if (distribution?.supported !== true)
if (distribution?.hopperSetupSupported !== true)
return { status: "failed", reason: "unsupported_host" };
let temporary: string | undefined;
try {

View File

@ -43,7 +43,7 @@ export const executeProviderOperation = async <
provider: ProviderOperationPort<TRequest>,
confidence: "observed" | "derived",
): Promise<Result<Evidence, AnalysisError>> => {
if (options?.signal?.aborted === true)
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(operation));
const request = schema.safeParse({ operation, input });
if (!request.success)
@ -51,8 +51,10 @@ export const executeProviderOperation = async <
const executed = await provider.execute(request.data, options);
if (!executed.ok) return executed;
const execution = executed.value;
return ok(
createEvidence(execution.subject ?? undefined, execution.provider, {
const evidence = createEvidence(
execution.subject ?? undefined,
execution.provider,
{
operation,
parameters: jsonObjectSchema.parse(request.data.input),
result: execution.result,
@ -60,6 +62,16 @@ export const executeProviderOperation = async <
limitations: execution.limitations,
locations: execution.locations,
confidence,
}),
},
);
// A provider that cannot observe the signal may finish its work after the
// caller cancelled, so re-check rather than reporting a completed operation
// the caller no longer wants. The observation it completed is still this
// provider's Evidence, so the cancellation carries it as the partial
// observation instead of discarding a finished result.
if (options?.signal?.aborted)
return err(
new AnalysisCancelledError(operation, { partialObservation: evidence }),
);
return ok(evidence);
};

View File

@ -57,7 +57,7 @@ describe("setup workflow", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
hopperSetupSupported: true,
};
host.hopper = "/opt/hopper/bin/Hopper";
host.linuxDemoRuntimeMissing = true;
@ -79,7 +79,7 @@ describe("setup workflow", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
hopperSetupSupported: true,
};
host.hopper = "/custom/Hopper";
host.unsupportedHopperVersion = true;

View File

@ -70,7 +70,7 @@ describe("setup workflow", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
hopperSetupSupported: true,
};
const result = await runSetup(
{ ...options(false), structured: false },
@ -162,7 +162,7 @@ describe("setup workflow", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
hopperSetupSupported: true,
};
const result = await runSetup(options(true, true), host);
expect(result.appliedActions).toEqual(["installed_hopper"]);
@ -236,7 +236,7 @@ describe("setup workflow lifecycle and provider planning", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
hopperSetupSupported: true,
};
host.ghidra = "/opt/ghidra_12.1.4_PUBLIC";
host.javaHome = "/usr/lib/jvm/jdk-21";

View File

@ -310,7 +310,7 @@ describe("setup scoped readiness", () => {
id: "debian",
versionId: "13",
packageFamily: "deb",
supported: false,
hopperSetupSupported: false,
};
const result = await runSetup(options(true, true), host);
expect(result.status).toBe("needs_human");
@ -326,7 +326,7 @@ describe("setup scoped readiness", () => {
id: "debian",
versionId: "13",
packageFamily: "deb",
supported: false,
hopperSetupSupported: false,
};
host.ghidra = "/opt/ghidra_12.1.4_PUBLIC";
host.javaHome = "/usr/lib/jvm/jdk-21";

View File

@ -704,6 +704,14 @@ const clientConfigurationDesired = (
launcher,
environment.value,
);
const previous = parsed?.servers[PRODUCT_IDENTITY.mcpServerKey];
if (
client.name === "kimi" &&
typeof previous === "object" &&
previous !== null &&
"enabledTools" in previous
)
registration.enabledTools = previous.enabledTools;
return {
...registration,
...(client.name === "codex" || client.name === "grok_build"
@ -711,5 +719,11 @@ const clientConfigurationDesired = (
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
}
: {}),
...(client.name === "kimi"
? {
startupTimeoutMs: MCP_STARTUP_POLICY.kimiStartupTimeoutMs,
toolTimeoutMs: MCP_STARTUP_POLICY.kimiToolTimeoutMs,
}
: {}),
};
};

View File

@ -124,7 +124,8 @@ export const hostRemediation = async (
? "Upgrade to macOS 12 or newer."
: undefined;
}
if ((await host.linuxDistribution())?.supported === true) return undefined;
if ((await host.linuxDistribution())?.hopperSetupSupported === true)
return undefined;
return "Automated Hopper setup supports Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, and Omarchy; configure an existing supported provider instead.";
};

View File

@ -56,7 +56,8 @@ export const discoverSetupState = async (input: {
const hopperInstallSupported =
(input.host.platform === "darwin" &&
majorVersion((await input.host.macosVersion()) ?? "0") >= 12) ||
(input.host.platform === "linux" && linuxDistribution?.supported === true);
(input.host.platform === "linux" &&
linuxDistribution?.hopperSetupSupported === true);
const installHopper =
input.forceHopperInstall ||
(hopperInstallSupported &&

View File

@ -72,6 +72,15 @@ interface CanonicalSkillFile {
readonly original: SkillFileState | undefined;
}
const canonicalSkillContent = (relativePath: string): Promise<string> =>
readFile(
new URL(
`../../skills/${PRODUCT_IDENTITY.skillName}/${relativePath}`,
import.meta.url,
),
"utf8",
);
export interface SkillInstallFileOperations {
readonly mkdir: (path: string) => Promise<void>;
readonly backup: (source: string, destination: string) => Promise<void>;
@ -244,19 +253,36 @@ const canonicalSkillFiles = async (
const destination = join(root, relativePath);
return {
destination,
content: await readFile(
new URL(
`../../skills/${PRODUCT_IDENTITY.skillName}/${relativePath}`,
import.meta.url,
),
"utf8",
),
content: await canonicalSkillContent(relativePath),
original: await readOptionalSkillState(destination, resources),
};
}),
),
);
const canonicalSkillNeedsInstallByReading = async (
home: string,
clientIds: readonly string[],
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform,
resources?: ArtifactResourceScope,
): Promise<boolean> => {
for (const { path: root } of skillDestinations(
home,
clientIds,
environment,
platform,
)) {
for (const relativePath of MANAGED_SKILL_FILES) {
const destination = join(root, relativePath);
const content = await canonicalSkillContent(relativePath);
const original = await readOptionalSkillState(destination, resources);
if (original?.content !== content) return true;
}
}
return false;
};
/** Report whether setup would change any file in the managed REA skill bundle. */
export const canonicalSkillNeedsInstall = async (
home: string,
@ -266,15 +292,13 @@ export const canonicalSkillNeedsInstall = async (
resources?: ArtifactResourceScope,
): Promise<boolean> => {
try {
return (
await canonicalSkillFiles(
home,
clientIds,
environment,
platform,
resources,
)
).some(({ content, original }) => original?.content !== content);
return await canonicalSkillNeedsInstallByReading(
home,
clientIds,
environment,
platform,
resources,
);
} catch (cause: unknown) {
if (
cause instanceof RegularFileCleanupFailure ||

View File

@ -45,6 +45,7 @@ export interface SetupClient {
| "grok"
| "omp"
| "pi"
| "kimi"
| "hermes"
| "zcode"
| "dsh"
@ -165,6 +166,10 @@ const claudeCodeMarkerDirectory = ({ home, env }: ClientPathContext): string =>
const codexDirectory = ({ home, env }: ClientPathContext): string =>
env.CODEX_HOME || join(home, ".codex");
/** Kimi Code stores MCP declarations beside the rest of its home data. */
const kimiDirectory = ({ home, env }: ClientPathContext): string =>
env.KIMI_CODE_HOME || join(home, ".kimi-code");
const qoderDirectory = ({ home, env }: ClientPathContext): string =>
env.QODER_CONFIG_DIR || join(home, ".qoder");
@ -534,7 +539,7 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
configPath: ({ home, platform, env }: ClientPathContext) =>
join(devinDirectory({ home, platform, env }), "mcp_config.json"),
markerPath: devinDirectory,
format: "json",
format: "devin",
},
{
name: "opencode",
@ -576,6 +581,14 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
markerPath: qwenCodeDirectory,
format: "json",
},
{
name: "kimi",
displayName: "Kimi Code",
configPath: (context: ClientPathContext) =>
join(kimiDirectory(context), "mcp.json"),
markerPath: kimiDirectory,
format: "kimi",
},
{
name: "vscode",
displayName: "VS Code",

View File

@ -627,11 +627,16 @@ const removeManagedSkills = async (
platform: NodeJS.Platform,
writeBlocker?: (path: string) => string | undefined,
): Promise<UninstallItem> => {
// Every destination shares one summary item, so each result is labeled by
// the directory it refers to.
// Every destination shares one summary item; preserve both client and path.
const results = await Promise.all(
skillDestinations(home, undefined, environment, platform).map(({ path }) =>
removeManagedSkill(path, path, fileSystem, writeBlocker),
skillDestinations(home, undefined, environment, platform).map(
({ client, path }) =>
removeManagedSkill(
path,
`${client} (${path})`,
fileSystem,
writeBlocker,
),
),
);
const failed = results.find(({ status }) => status === "failed");

View File

@ -83,3 +83,37 @@ it("preserves cancellation before execution", async () => {
if (result.ok) return;
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
});
it("preserves a cancellation that races a completed observation", async () => {
const controller = new AbortController();
const racingPort: ApktoolResourceAnalysisPort = {
async inspectAvailability() {
return { status: "available", code: null, reason: null, diagnostics: {} };
},
async close() {},
async execute() {
// The provider may finish its work before the abort is delivered, so a
// successful result is not by itself proof the caller still wants it.
controller.abort();
return ok(execution);
},
};
const result = await new ApktoolResourceAnalysisService(racingPort).execute(
"decode_android_resources",
{ path: "/targets/app.apk", include_strings: true },
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
// The observation the provider completed is retained on the cancellation, so
// a session still records the Evidence instead of losing finished work.
const retained = parseEvidence(result.error.partialObservation);
expect(retained.operation).toBe("decode_android_resources");
expect(retained.provider).toEqual(identity);
expect(retained.parameters).toEqual({
path: "/targets/app.apk",
include_strings: true,
});
expect(retained.confidence).toBe("observed");
});

View File

@ -283,7 +283,9 @@ export class BinarySession
previous.route.identity.id,
);
if (!closed.ok) {
this.clearSessionRecords();
// A failed open retires the provider cache, not the investigation.
// Preserve Evidence and Unknown history for export and recovery.
this.clearSnapshot();
this.#clearRuntimeAvailability();
return closed;
}

View File

@ -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 },

View File

@ -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,

View File

@ -100,4 +100,10 @@ const parameters = (
...(input.source_paths === undefined
? {}
: { source_paths: input.source_paths }),
...(input.max_heap_mb === undefined
? {}
: { max_heap_mb: input.max_heap_mb }),
...(input.analysis_timeout_ms === undefined
? {}
: { analysis_timeout_ms: input.analysis_timeout_ms }),
});

View File

@ -62,26 +62,7 @@ export const analyzeJavaScriptApplicationValidated = async (
});
await checkpointJavaScriptAnalysis(options.signal);
reconstructed = await reconstructJavaScriptArtifact(
{
input_path: input.input_path,
format: input.format,
integrity_policy: input.integrity_policy,
...(input.max_semantic_nodes === undefined
? {}
: { max_semantic_nodes: input.max_semantic_nodes }),
...(input.max_semantic_nodes_per_file === undefined
? {}
: { max_semantic_nodes_per_file: input.max_semantic_nodes_per_file }),
...(input.source_paths === undefined
? {}
: { source_paths: input.source_paths }),
...(input.max_heap_mb === undefined
? {}
: { max_heap_mb: input.max_heap_mb }),
...(input.analysis_timeout_ms === undefined
? {}
: { analysis_timeout_ms: input.analysis_timeout_ms }),
},
input,
scope,
options.signal,
options.progress,

View File

@ -457,9 +457,8 @@ const bareModuleCandidate = (
dependencyDirectory = posix.dirname(confined);
}
const source = input.files.get(input.sourcePath);
let directory = posix.dirname(input.sourcePath);
let nearestNotFound: CandidateResolution | null = null;
while (true) {
for (const directory of packageLookupDirectories(input)) {
const candidate = posix.join(directory, dependencyDirectory);
const metadata = readPackageMetadata(input, candidate);
const continueCommonJsLookup =
@ -487,8 +486,6 @@ const bareModuleCandidate = (
nearestNotFound ??= packageResolution;
} else return packageResolution;
}
if (directory === "." || directory === "") break;
directory = posix.dirname(directory);
}
if (nearestNotFound !== null) return nearestNotFound;
return unresolvedCandidate("external", [
@ -496,6 +493,21 @@ const bareModuleCandidate = (
]);
};
function* packageLookupDirectories(
input: ResolveArtifactPathInput,
): Generator<string> {
let directory = posix.dirname(input.sourcePath);
while (true) {
if (
input.moduleKind !== "require" ||
posix.basename(directory) !== "node_modules"
)
yield directory;
if (directory === "." || directory === "") return;
directory = posix.dirname(directory);
}
}
/**
* Nearest same-container package.json, stopping at a `node_modules` directory
* so a nested package cannot inherit an ancestor's map.
@ -960,15 +972,29 @@ const resolveCandidate = (
candidate: string,
packageChain: ReadonlySet<string>,
emittedSourceCandidates: readonly TypeScriptEmittedSourceCandidate[] = [],
): CandidateResolution =>
resolveFileCandidates(input, fileCandidates(candidate, input.moduleKind)) ??
resolveRuntimeSourceCandidates(input, candidate, emittedSourceCandidates) ??
resolveDirectory(
): CandidateResolution => {
if (!commonJsDirectoryRequest(input)) {
const file =
resolveFileCandidates(
input,
fileCandidates(candidate, input.moduleKind),
) ??
resolveRuntimeSourceCandidates(input, candidate, emittedSourceCandidates);
if (file !== null) return file;
}
return resolveDirectory(
input,
candidate,
readPackageMetadata(input, candidate),
packageChain,
);
};
const commonJsDirectoryRequest = (input: ResolveArtifactPathInput): boolean =>
input.context === "module-specifier" &&
input.moduleKind === "require" &&
(input.declaredPath.endsWith("/") ||
/(?:^|\/)\.{1,2}$/u.test(input.declaredPath));
const resolveDirectory = (
input: ResolveArtifactPathInput,

View File

@ -277,15 +277,9 @@ export const reconstructJavaScriptArtifact = async (
limitations: completed.graph.limitations,
}),
);
const { max_heap_mb, analysis_timeout_ms, ...coreInput } = input;
const parameters = {
...coreInput,
...(max_heap_mb === undefined ? {} : { max_heap_mb }),
...(analysis_timeout_ms === undefined ? {} : { analysis_timeout_ms }),
};
const evidence =
await createOwnedJavaScriptApplicationEvidenceCooperatively(
parameters,
input,
partial,
);
throw interruption.retainPartialObservation(evidence);

View File

@ -199,6 +199,49 @@ const testAnalysis = (
});
describe("native UI action trace", () => {
it("retains Hopper targetless call evidence alongside fallback callee routes", async () => {
const result = await new EnhancedTools(
testAnalysis({
procedure_address: () => execution("procedure_address", "0x1000"),
procedure_references: (parameters) =>
ok(
createAnalysisExecution(
{
reference_kinds_available: false,
references: [],
unresolved_calls:
parameters.procedure === "0x1000"
? [{ address: "0x1008", reason: "No static target" }]
: [],
},
{ id: "hopper", name: "Hopper", version: "fixture" },
),
),
}),
).execute("trace_native_ui_action", { action: "0x1000" });
if (!result.ok) throw result.error;
const trace = nativeInvestigationTraceSchema.parse(result.value);
expect(trace.edges).toContainEqual(
expect.objectContaining({
relation: "provider_call",
to: "native:function:0x2000",
}),
);
expect(trace.unresolved).toContainEqual(
expect.objectContaining({
from: "native:function:0x1000",
to: null,
reason: "No static target",
evidence: [
expect.objectContaining({
description: "Hopper reported an unresolved call: No static target",
location: { address: "0x1008", file_offset: null },
artifact_sha256: target,
}),
],
}),
);
});
it("accepts an explicit native seed without an authored UI connection", async () => {
const result = await new EnhancedTools(
testAnalysis({

View File

@ -7,6 +7,59 @@ import { err, ok } from "../../domain/result.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
const provider = { id: "fixture", name: "Fixture", version: "1" };
describe("untyped native call route fallback", () => {
it.each([{ callees: [] }, { callees: ["0x2000"] }])(
"preserves targetless calls and their provider when fallback callees are $callees",
async ({ callees }) => {
const calls: string[] = [];
const referenceProvider = { ...provider, id: "hopper", name: "Hopper" };
const fallbackProvider = { ...provider, id: "fallback" };
const signal = new AbortController().signal;
const unknown = { address: "0x1008", reason: "No static target" };
const result = await readNativeCallRoutes(
{
execute: async (operation, parameters, options) => {
calls.push(operation);
expect(parameters.procedure).toBe("0x1000");
expect(options).toEqual({ signal, selectorOrigin: "provider" });
return ok(
createAnalysisExecution(
operation === "procedure_references"
? {
reference_kinds_available: false,
references: [],
unresolved_calls: [unknown],
}
: callees,
operation === "procedure_references"
? referenceProvider
: fallbackProvider,
),
);
},
},
"0x1000",
signal,
"provider",
);
expect(calls).toEqual(["procedure_references", "procedure_callees"]);
expect(result).toEqual({
ok: true,
value: {
provider: fallbackProvider,
routes: callees.map((address) => ({
address,
site: "0x1000",
relation: "provider_call",
resolution: "inferred",
})),
unknowns: [{ ...unknown, provider: referenceProvider }],
},
});
},
);
});
describe("typed native call routes", () => {
it("preserves computed targets, ambiguity and targetless call sites", async () => {
const reference = jsonValueSchema.parse(ghidraReferenceEdge());

View File

@ -2,12 +2,12 @@ import { z } from "zod";
import type {
AnalysisOperationPort,
ExecutionOptions,
ProviderIdentity,
} from "../AnalysisProvider.js";
import {
parseRelatedAddresses,
referenceKindSchema,
} from "../../domain/hopperValues.js";
import { err, ok } from "../../domain/result.js";
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
const referencesSchema = z.object({
@ -40,15 +40,29 @@ export const readNativeCallRoutes = async (
{ procedure, direction: "outgoing" },
options,
);
let unknowns: Array<{
address: string;
reason: string;
provider: ProviderIdentity;
}> = [];
if (execution.ok) {
const parsed = referencesSchema.safeParse(execution.value.result);
if (!parsed.success)
return err(
new AnalysisOutputError(
return {
ok: false as const,
error: new AnalysisOutputError(
"procedure_references",
"Invalid typed call references",
),
);
unknowns,
};
// Missing detailed reference flags do not invalidate observed targetless
// calls. Keep their producing provider even if another provider supplies
// the fallback callee inventory.
unknowns = parsed.data.unresolved_calls.map((call) => ({
...call,
provider: execution.value.provider,
}));
const references = parsed.data.references;
if (
parsed.data.reference_kinds_available === true ||
@ -64,45 +78,52 @@ export const readNativeCallRoutes = async (
targets.add(call.target_address);
countBySite.set(call.source_address, targets);
}
return ok({
provider: execution.value.provider,
routes: calls.map((call) => ({
address: call.target_procedure?.address ?? call.target_address,
site: call.source_address,
relation:
call.kind.available && (call.kind.computed || call.kind.indirect)
? ("indirect_call" as const)
: ("direct_call" as const),
resolution:
(countBySite.get(call.source_address)?.size ?? 0) > 1
? ("ambiguous" as const)
: call.kind.available &&
(call.kind.computed || call.kind.indirect)
? ("resolved" as const)
: ("observed" as const),
})),
unknowns: parsed.data.unresolved_calls,
});
return {
ok: true as const,
value: {
provider: execution.value.provider,
routes: calls.map((call) => ({
address: call.target_procedure?.address ?? call.target_address,
site: call.source_address,
relation:
call.kind.available && (call.kind.computed || call.kind.indirect)
? ("indirect_call" as const)
: ("direct_call" as const),
resolution:
(countBySite.get(call.source_address)?.size ?? 0) > 1
? ("ambiguous" as const)
: call.kind.available &&
(call.kind.computed || call.kind.indirect)
? ("resolved" as const)
: ("observed" as const),
})),
unknowns,
},
};
}
} else if (execution.error._tag !== "AnalysisCapabilityUnavailableError")
return err(execution.error);
return { ok: false as const, error: execution.error, unknowns };
const callees = await analysis.execute(
"procedure_callees",
{ procedure },
options,
);
if (!callees.ok) return callees;
if (!callees.ok)
return { ok: false as const, error: callees.error, unknowns };
const parsed = parseRelatedAddresses(callees.value.result, "callees");
return parsed.ok
? ok({
provider: callees.value.provider,
routes: parsed.value.map((address) => ({
address,
site: procedure,
relation: "provider_call" as const,
resolution: "inferred" as const,
})),
unknowns: [],
})
: parsed;
? {
ok: true as const,
value: {
provider: callees.value.provider,
routes: parsed.value.map((address) => ({
address,
site: procedure,
relation: "provider_call" as const,
resolution: "inferred" as const,
})),
unknowns,
},
}
: { ok: false as const, error: parsed.error, unknowns };
};

View File

@ -414,9 +414,44 @@ const addDirectCallees = async (input: {
"provider",
);
examined += 1;
if (!execution.ok && execution.error._tag === "AnalysisCancelledError")
return { error: execution.error };
const unknowns = execution.ok
? execution.value.unknowns
: execution.unknowns;
for (const [index, unknown] of unknowns.entries()) {
if (edges.size >= input.input.max_edges) {
truncated = true;
omitted += unknowns.length - index;
break;
}
const id = `unknown-dispatch:${current.address}:${unknown.address}`;
edges.set(id, {
id,
from: current.node.id,
to: null,
relation: "indirect_call",
resolution: "unresolved",
reason: unknown.reason,
evidence: [
{
kind: "reference",
provider: unknown.provider,
description: `${unknown.provider.name} reported an unresolved call: ${unknown.reason}`,
location: { address: unknown.address, file_offset: null },
artifact_path: null,
artifact_sha256: input.targetSha256,
},
],
limitations: [],
});
}
if (!execution.ok) {
if (execution.error._tag === "AnalysisCancelledError")
return { error: execution.error };
if (edges.size >= input.input.max_edges) {
truncated = true;
omitted += 1;
continue;
}
const id = `unknown-callees:${current.address}`;
edges.set(id, {
id,
@ -432,32 +467,6 @@ const addDirectCallees = async (input: {
});
continue;
}
for (const unknown of execution.value.unknowns) {
if (edges.size >= input.input.max_edges) {
truncated = true;
omitted++;
break;
}
const id = `unknown-dispatch:${current.address}:${unknown.address}`;
edges.set(id, {
id,
from: current.node.id,
to: null,
relation: "indirect_call",
resolution: "unresolved",
reason: unknown.reason,
evidence: [
{
kind: "reference",
description: unknown.reason,
location: { address: unknown.address, file_offset: null },
artifact_path: null,
artifact_sha256: input.targetSha256,
},
],
limitations: [],
});
}
for (const [index, route] of execution.value.routes.entries()) {
const address = route.address;
if (edges.size >= input.input.max_edges) {
@ -469,6 +478,7 @@ const addDirectCallees = async (input: {
const evidence = [
{
kind: "reference" as const,
provider: execution.value.provider,
description: `${execution.value.provider.name} reported ${route.relation} from ${current.address} to ${address}`,
location: { address: route.site, file_offset: null },
artifact_path: null,

View File

@ -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" }],
},
);
},
);

View File

@ -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),
},
]),
);
}
};

View File

@ -1,25 +0,0 @@
import { describe, expect, it } from "vitest";
import { orderAsarListing } from "./AsarArtifactReader.js";
describe("orderAsarListing", () => {
it("orders host-spelled listings by their portable archive path", () => {
const posix = orderAsarListing(["/a0", "/a", "/a/b"], "/");
const windows = orderAsarListing(["\\a0", "\\a", "\\a\\b"], "\\");
// "/" (0x2F) precedes "0" (0x30); "\" (0x5C) would follow it.
expect(posix.map(({ path }) => path)).toEqual(["a", "a/b", "a0"]);
expect(windows.map(({ path }) => path)).toEqual(["a", "a/b", "a0"]);
expect(windows.map(({ listed }) => listed)).toEqual([
"\\a",
"\\a\\b",
"\\a0",
]);
});
it("keeps POSIX backslashes as literal filename characters", () => {
expect(orderAsarListing(["/a\\b"], "/")).toEqual([
{ listed: "/a\\b", path: "a\\b" },
]);
});
});

View File

@ -13,7 +13,7 @@ import { isAbsolute, join, relative, sep } from "node:path";
import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { listPackage, statFile, uncache } from "@electron/asar";
import { getRawHeader, uncache } from "@electron/asar";
import {
ArtifactReaderFailure,
@ -26,7 +26,11 @@ import { closeAsarHandle, readValidatedAsarEntry } from "./AsarEntryStream.js";
import { admitAsarHeader } from "./AsarHeader.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import {
readAsarListing,
type AsarListedEntry,
type AsarMetadata,
} from "./AsarListing.js";
/**
* Official Electron ASAR adapter with range-streamed member reads.
@ -51,7 +55,7 @@ export class AsarArtifactReader implements ArtifactReader {
) {}
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
let paths: readonly AsarListedPath[];
let paths: readonly AsarListedEntry[];
try {
await this.prepareContainer(undefined, signal);
paths = await this.#withSnapshotOperation(async () => {
@ -62,8 +66,9 @@ export class AsarArtifactReader implements ArtifactReader {
this.#archiveSize = admitted.archiveBytes;
this.#headerSize = admitted.headerBytes;
abortIfNeeded(signal);
const ordered = orderAsarListing(
listPackage(snapshotPath, { isPack: false }),
const ordered = await readAsarListing(
getRawHeader(snapshotPath).header,
signal,
);
const archiveMetadata = await lstat(snapshotPath);
if (!archiveMetadata.isFile() || archiveMetadata.isSymbolicLink())
@ -77,34 +82,25 @@ export class AsarArtifactReader implements ArtifactReader {
} catch (cause: unknown) {
throw asarFailure(this.path, "inventory", cause);
}
for (const { listed, path } of paths) {
for (const { metadata, path } of paths) {
abortIfNeeded(signal);
if (path.length === 0) continue;
const providerPath = listed.startsWith(sep)
? listed.slice(sep.length)
: listed;
let metadata: ReturnType<typeof statFile>;
try {
metadata = statFile(this.#snapshotPath(), providerPath, false);
} catch (cause: unknown) {
throw asarFailure(this.path, `stat ${path}`, cause);
}
const providerPath = path.split("/").join(sep);
const kind =
"files" in metadata
metadata.files !== undefined
? "directory"
: "link" in metadata
: metadata.link !== undefined
? "symlink"
: "file";
const entry: ArtifactEntry = {
path,
kind,
declaredSize: "size" in metadata ? metadata.size : null,
declaredSize: metadata.size ?? null,
compressedSize: null,
executable: "executable" in metadata && metadata.executable,
executable: metadata.executable === true,
encrypted: false,
byteOffset: null,
declaredSha256:
"integrity" in metadata &&
metadata.integrity !== undefined &&
metadata.integrity.algorithm === "SHA256" &&
/^[a-f0-9]{64}$/u.test(metadata.integrity.hash)
? metadata.integrity.hash
@ -603,7 +599,7 @@ export class AsarArtifactReader implements ArtifactReader {
}
}
type AsarFileMetadata = Extract<ReturnType<typeof statFile>, { size: number }>;
type AsarFileMetadata = AsarMetadata & { readonly size: number };
type AsarEntryState = {
readonly metadata: AsarFileMetadata;
readonly entry: ArtifactEntry;
@ -652,12 +648,11 @@ const asarUnpackedEntryFailure = (
};
const isAsarFileMetadata = (
metadata: ReturnType<typeof statFile>,
metadata: AsarMetadata,
): metadata is AsarFileMetadata =>
"size" in metadata &&
typeof metadata.size === "number" &&
!("files" in metadata) &&
!("link" in metadata);
metadata.files === undefined &&
metadata.link === undefined;
const parseArchiveOffset = (value: string): number | undefined => {
if (!/^(?:0|[1-9][0-9]*)$/u.test(value)) return undefined;
@ -699,33 +694,6 @@ const waitForOperation = async (
});
};
interface AsarListedPath {
readonly listed: string;
readonly path: string;
}
/**
* Pair each host-spelled listing with its portable archive path, ordered by
* the portable path so Windows and POSIX hosts agree.
*
* @electron/asar returns paths assembled with the host path module. Keep that
* spelling for its API calls, but expose archive paths with portable
* separators. On POSIX, backslashes can be literal filename characters and
* must remain untouched.
*/
export const orderAsarListing = (
listed: readonly string[],
separator: string = sep,
): AsarListedPath[] =>
listed
.map((entry) => ({ listed: entry, path: toArtifactPath(entry, separator) }))
.sort((left, right) => compareUnicodeCodePoints(left.path, right.path));
const toArtifactPath = (listed: string, separator: string): string => {
const portable = separator === "\\" ? listed.replaceAll("\\", "/") : listed;
return portable.replace(/^\/+|\/+$/gu, "");
};
const asarFailure = (
path: string,
operation: string,

View File

@ -0,0 +1,110 @@
import { setImmediate } from "node:timers/promises";
import { z } from "zod";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
/** Capacity for expanded paths and listing records, independent of packed bytes. */
const MAX_LISTING_BYTES = 64 * 1024 * 1024;
const nodeSchema = z.looseObject({
files: z
.custom<Record<string, unknown>>(
(value) =>
value !== null && typeof value === "object" && !Array.isArray(value),
)
.optional(),
link: z.string().optional(),
size: z.number().nonnegative().safe().optional(),
offset: z.string().optional(),
unpacked: z.boolean().optional(),
executable: z.boolean().optional(),
integrity: z
.looseObject({ algorithm: z.string(), hash: z.string() })
.optional(),
});
export type AsarMetadata = z.infer<typeof nodeSchema>;
export interface AsarListedEntry {
readonly path: string;
readonly metadata: AsarMetadata;
}
function* ownChildren(files: Record<string, unknown>) {
for (const name in files)
if (Object.hasOwn(files, name)) yield [name, files[name]] as const;
}
/** Decode each node once; admit expanded paths before concatenation and retain no recursive lookup. */
export const readAsarListing = async (
header: unknown,
signal?: AbortSignal,
): Promise<AsarListedEntry[]> => {
const root = nodeSchema.parse(header);
if (root.files === undefined)
throw new ArtifactReaderFailure("format", "ASAR root has no files table");
const entries: AsarListedEntry[] = [];
const pending = [{ parent: "", children: ownChildren(root.files) }];
let retainedBytes = 0;
let examined = 0;
while (pending.length > 0) {
if (examined++ % 128 === 0) {
await setImmediate();
if (signal?.aborted)
throw new ArtifactReaderFailure(
"cancelled",
"ASAR operation cancelled",
);
}
const frame = pending.at(-1);
if (frame === undefined) break;
const next = frame.children.next();
if (next.done) {
pending.pop();
continue;
}
const [name, raw] = next.value;
// A header member is one component, not a host path to normalize. POSIX
// backslashes remain literal; Windows cannot represent them as components.
if (
name.length === 0 ||
name === "." ||
name === ".." ||
name.includes("/") ||
name.includes("\0") ||
(process.platform === "win32" && name.includes("\\"))
)
throw new ArtifactReaderFailure(
"path",
"ASAR header has an unsafe path component",
);
const units =
frame.parent.length + name.length + (frame.parent.length === 0 ? 0 : 1);
// UTF-16 storage, plus records and traversal bookkeeping. Charge every
// occurrence even when the engine can share a string prefix.
retainedBytes += units * 2 + 256;
if (retainedBytes > MAX_LISTING_BYTES)
throw new ArtifactReaderFailure(
"limit",
"ASAR expanded listing exceeds its 64 MiB representation budget",
);
const path = frame.parent.length === 0 ? name : `${frame.parent}/${name}`;
const metadata = nodeSchema.parse(raw);
if (
metadata.files === undefined &&
metadata.link === undefined &&
metadata.size === undefined
)
throw new ArtifactReaderFailure(
"format",
"ASAR entry has no directory, link or file metadata",
);
entries.push({ path, metadata });
if (metadata.files !== undefined)
pending.push({
parent: path,
children: ownChildren(metadata.files),
});
}
return entries.sort((left, right) =>
compareUnicodeCodePoints(left.path, right.path),
);
};

View File

@ -0,0 +1,84 @@
import { expect, it } from "vitest";
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
import { captureStorage } from "./CdpCaptureStorage.js";
import { CdpCommandRejection } from "./CdpCommandRejection.js";
import type { CdpConnection } from "./CdpConnection.js";
const NO_CACHE_COMMAND = Symbol("unsupported");
const captureWithCacheResult = async (
cacheResult: unknown,
): Promise<Awaited<ReturnType<typeof captureStorage>>["value"]> => {
const connection = {
send(method: string): Promise<unknown> {
switch (method) {
case "Storage.getUsageAndQuota":
return Promise.resolve({ usage: 1, quota: 10 });
case "DOMStorage.getDOMStorageItems":
return Promise.resolve({ entries: [] });
case "IndexedDB.requestDatabaseNames":
return Promise.resolve({ databaseNames: [] });
case "Network.getCookies":
return Promise.resolve({ cookies: [] });
case "CacheStorage.requestCacheNames":
return cacheResult === NO_CACHE_COMMAND
? Promise.reject(
new CdpCommandRejection(
"inspect_web_page",
method,
-32601,
"Method not found",
),
)
: Promise.resolve(cacheResult);
case "CacheStorage.requestEntries":
return Promise.resolve({ cacheDataEntries: [], returnCount: 0 });
default:
return Promise.reject(new Error(`Unexpected command: ${method}`));
}
},
} as unknown as CdpConnection;
const { value } = await captureStorage(
{
connection,
sessionId: undefined,
input: inspectWebPageInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "fixture-page",
include_storage_keys: true,
include_storage_fingerprints: true,
}),
},
"https://example.test/",
[],
);
return value;
};
it.each([
["an observed empty cache list", { caches: [] }, true],
["a response without a cache list", {}, false],
["a non-array cache list", { caches: "nope" }, false],
["a cache list with an unusable entry", { caches: [5] }, false],
["an unsupported cache command", NO_CACHE_COMMAND, false],
] as const)(
"reports fingerprint completeness for %s",
async (_label, cacheResult, complete) => {
const value = await captureWithCacheResult(cacheResult);
expect(value.cache_names).toEqual([]);
expect(value.fingerprints_complete).toBe(complete);
},
);
it.each([false, true])(
"preserves valid cache evidence beside an unusable entry: %s",
async (mixed) => {
const cache = { cacheName: "fixture-cache", cacheId: "fixture-cache-id" };
const value = await captureWithCacheResult({
caches: mixed ? [cache, 5] : [cache],
});
expect(value.cache_names).toEqual(["fixture-cache"]);
expect(value.fingerprints_complete).toBe(!mixed);
},
);

View File

@ -60,8 +60,8 @@ export const captureStorage = async (
limitations,
)
: undefined;
const cacheValues = recordsValue(recordValue(cacheRaw)?.caches);
const caches = capturedCaches(cacheValues);
const cacheField = recordValue(cacheRaw)?.caches;
const caches = capturedCaches(recordsValue(cacheField));
const fingerprints = context.input.include_storage_fingerprints
? await captureStorageFingerprints({
context,
@ -78,8 +78,10 @@ export const captureStorage = async (
indexedRaw !== undefined &&
Array.isArray(indexedValues) &&
indexed.length === indexedValues.length &&
cacheRaw !== undefined &&
caches.length === cacheValues.length;
// Like IndexedDB names, a missing, non-array or partly unusable cache
// list is an incomplete enumeration rather than an empty one.
Array.isArray(cacheField) &&
caches.length === cacheField.length;
return {
value: {
origin,

View File

@ -8,6 +8,8 @@ import {
FIELD_RECORD_BUDGET,
} from "./parseFieldDiscovery.js";
import {
BOUNDED_PRODUCER_LIMITS as LIMITS,
prlimitArgumentVector,
readLinuxResourceLimits,
type LinuxResourceLimits,
} from "../process/LinuxResourceLimits.js";
@ -60,15 +62,6 @@ export const TSHARK_PROVIDER_IDENTITY = {
name: "REA TShark capture adapter",
version: "tshark@4",
} as const;
// Bound the resident input copy and retained text before allocation/collection.
const LIMITS = {
input_bytes: 32 * 1024 * 1024,
output_bytes: 4 * 1024 * 1024,
command_timeout_ms: 30_000,
address_space_bytes: 1024 * 1024 * 1024,
file_bytes: 64 * 1024 * 1024,
cpu_seconds: 30,
} as const;
type Launcher = NonNullable<Parameters<typeof runOwnedCommand>[2]>["launcher"];
/** Inspect selected capture files only, retaining cleanup ownership across failures. */
@ -165,14 +158,7 @@ export class TsharkCaptureProvider implements PacketCapturePort {
options?.signal,
);
effectiveLimits = await this.#resources.readLimits(limits);
const limitArguments = [
`--as=${effectiveLimits.address_space_bytes}:${effectiveLimits.address_space_bytes}`,
`--fsize=${effectiveLimits.file_bytes}:${effectiveLimits.file_bytes}`,
`--cpu=${effectiveLimits.cpu_seconds}:${effectiveLimits.cpu_seconds}`,
"--core=0:0",
"--",
command,
];
const limitArguments = prlimitArgumentVector(effectiveLimits, command);
phase = "workspace";
root = await this.createRuntime();
this.#resources.retain(root, undefined);

View File

@ -6,7 +6,10 @@ import { createApktoolResourceAnalysisProvider } from "../composition/apktool.js
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { ApktoolOperation } from "../domain/apktool/apktoolResourceAnalysis.js";
import {
apktoolInputSchemas,
type ApktoolOperation,
} from "../domain/apktool/apktoolResourceAnalysis.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
@ -49,22 +52,19 @@ export const registerApktoolCommands = (
args: z.object({
path: z.string().min(1).describe("Local APK file to decode"),
}),
// The locale grammar and the string-projection default belong to the
// apktool input owner. Read them from that schema rather than restating a
// narrower locale pattern, which refused the owner's own advertised
// `b+zh+Hans+CN` form at the CLI boundary.
options: z.object({
locale: z
.string()
.min(2)
.max(16)
.regex(
/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/u,
"Locale must look like de or pt-BR",
)
.optional()
.describe(
"Project strings from res/values-<locale> instead of the default",
),
locale: apktoolInputSchemas.decode_android_resources.shape.locale,
strings: z
.boolean()
.default(true)
.default(
apktoolInputSchemas.decode_android_resources.shape.include_strings.parse(
undefined,
),
)
.describe(
"Include string resources; use --no-strings to skip projecting them",
),

View File

@ -225,7 +225,7 @@ const registerInspectionCommand = (
description:
"Inspect one artifact and return all available observations and next probes",
args: z.object({
path: z.string().describe("Application or package path"),
path: z.string().min(1).describe("Application or package path"),
}),
options: z.object({
integrityPolicy: z

View File

@ -48,7 +48,10 @@ const registerOverviewCommands = (
cli.command(CLI_COMMANDS.analyze, {
description: "Get an overview of an app",
args: z.object({
path: z.string().describe("App, program, or analysis database path"),
path: z
.string()
.min(1)
.describe("App, program, or analysis database path"),
}),
options: overviewOptions.extend({
integrityPolicy: javascriptApplicationOptions.shape.integrityPolicy,

View File

@ -17,6 +17,46 @@ export const registerCoreNativeCommands = (
runDirectAnalysis: DirectAnalysis["runDirectAnalysis"],
): void => {
registerAnnotationCommand(cli, logger, runDirectAnalysis);
cli.command(CLI_COMMANDS.searchBytes, {
description: "Find masked byte patterns in initialized provider memory",
args: z.object({
path: z.string().describe("Local executable path"),
pattern: z.string().describe("Hexadecimal byte pairs with ?? wildcards"),
}),
options: z.object({
mask: z.string().optional().describe("Equal-length hexadecimal bit mask"),
start: z.string().optional().describe("Inclusive start address"),
end: z.string().optional().describe("Inclusive end address"),
cursor: z
.string()
.optional()
.describe("Continuation from the same query's next_cursor"),
limit: z
.number()
.int()
.min(1)
.max(1000)
.default(100)
.describe("Maximum matches on this page"),
...nativeOpenOptionFields,
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.searchBytes, () =>
runDirectAnalysis(
args.path,
"search_bytes",
{
pattern: args.pattern,
limit: options.limit,
...(options.mask === undefined ? {} : { mask: options.mask }),
...(options.start === undefined ? {} : { start: options.start }),
...(options.end === undefined ? {} : { end: options.end }),
...(options.cursor === undefined ? {} : { cursor: options.cursor }),
},
directAnalysisOptionsFromCli(logger, options),
),
),
});
cli.command(CLI_COMMANDS.inspectNativeLoadImage, {
description:
"Verify loaded native bytes, source mappings, relocations and entry",

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",

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",
@ -39,6 +40,7 @@ export const CLI_COMMANDS = Object.freeze({
inspectNativeDataType: "inspect-native-data-type",
inspectNativeLoadImage: "inspect-native-load-image",
readBytes: "read-bytes",
searchBytes: "search-bytes",
addressToFileOffset: "address-to-file-offset",
traceNativeValues: "trace-native-values",
inspectNativeInstruction: "inspect-native-instruction",

View File

@ -1,8 +1,27 @@
import { z } from "zod";
import { address, document } from "./toolContractHelpers.js";
import {
bytePatternFields,
validateBytePattern,
} from "../domain/native/bytePattern.js";
/** Hopper memory and file-mapping contracts. */
export const HOPPER_MEMORY_TOOL_DEFINITIONS = [
{
name: "search_bytes",
description:
"Find masked byte signatures in initialized analyzed memory. Hex byte pairs and ?? wildcards are supported; an optional equal-length hex mask selects bits. Bounds are inclusive. Matches are ordered by provider address and never span blocks. Coverage describes this page; reuse the same query with next_cursor to continue. Uninitialized blocks are listed separately. Ghidra only; Hopper is unavailable.",
inputSchema: z
.strictObject({
...bytePatternFields,
document,
start: address.optional(),
end: address.optional(),
limit: z.number().int().min(1).max(1000).default(100),
cursor: z.string().min(1).optional(),
})
.superRefine(validateBytePattern),
},
{
name: "read_bytes",
description:

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();

View File

@ -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: [

View File

@ -26,6 +26,7 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly<
resolve_native_call_targets: { address: "0x1000" },
read_function_instructions: { procedure: "main" },
read_bytes: { address: "0x1000", length: 16 },
search_bytes: { pattern: "48 8B ?? ?? 89" },
address_to_file_offset: { address: "0x1000" },
procedure_references: { procedure: "main" },
procedure_pseudo_code: { procedure: "main" },

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:

View File

@ -438,6 +438,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
inspect_native_data_type: evidence,
resolve_native_call_targets: evidence,
read_bytes: evidence,
search_bytes: evidence,
address_to_file_offset: evidence,
procedure_references: evidence,
procedure_pseudo_code: evidence,
@ -622,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,

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([

View File

@ -1,4 +1,5 @@
import { z } from "zod";
import { byteSearchResultSchema } from "../domain/native/bytePattern.js";
import {
artifactInventorySummaryViewResultSchema,
machoSummaryViewResultSchema,
@ -108,6 +109,7 @@ const bookmarkFacetSchema = z.discriminatedUnion("state", [
/** Exact structured-content schemas shared by direct analysis providers. */
export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
search_bytes: evidenceResultOf(byteSearchResultSchema),
annotate_native_function: evidenceResultOf(nativeFunctionAnnotationsSchema),
inspect_native_load_image: evidenceResultOf(nativeLoadImageSchema),
inspect_native_data_type: evidenceResultOf(nativeDataTypeSchema),

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 }),
};
},

View File

@ -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;

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;

View File

@ -417,6 +417,34 @@ describe("Apple bundle layouts and containers", () => {
});
});
describe("Apple IPA components", () => {
it("excludes AppleDouble sidecars from IPA components", () => {
const result = project(
inventoryEvidence("ipa", "Fixture.ipa", [
{ path: "Payload/Fixture.app/Info.plist" },
macho("Payload/Fixture.app/Fixture"),
{ path: "Payload/Fixture.app/Contents/Resources/._libfoo.dylib" },
{ path: "Payload/Fixture.app/Frameworks/Foo.framework/._Foo" },
{ path: "__MACOSX/Payload/Fixture.app/libghost.dylib" },
macho("Payload/Fixture.app/Contents/Resources/libreal.dylib"),
macho("SwiftSupport/iphoneos/libswiftCore.dylib"),
]),
);
expect(result.components.native_libraries.map(({ path }) => path)).toEqual([
"Payload/Fixture.app/Contents/Resources/libreal.dylib",
"SwiftSupport/iphoneos/libswiftCore.dylib",
]);
expect(result.components.frameworks).toEqual([]);
expect(result.components.executables.map(({ path }) => path)).toEqual([
"Payload/Fixture.app/Contents/Resources/libreal.dylib",
"Payload/Fixture.app/Fixture",
]);
expect(result.limitations).toContainEqual(
expect.stringContaining("AppleDouble sidecar entries"),
);
});
});
describe("macOS application scope", () => {
it("attributes only components inside the application roots", () => {
const result = project(

View File

@ -218,8 +218,9 @@ export const appleProjectionId = (projection: unknown): string =>
`aap_${digest(projection)}`;
/**
* IPA projection keeps every archive component, as before. Directory, ZIP,
* and DMG inventories can hold installers and other siblings, so only
* AppleDouble sidecars describe neighbouring files and are never components.
* IPA projection keeps every other archive component, as before. Directory,
* ZIP, and DMG inventories can hold installers and other siblings, so only
* components inside an application root are attributed to it.
*/
const attributeComponents = (
@ -227,8 +228,8 @@ const attributeComponents = (
roots: readonly string[],
rootFormat: RootFormat,
): { readonly all: Component[]; readonly unattributed: number } => {
if (rootFormat === "ipa") return { all: [...inventoried], unattributed: 0 };
const visible = inventoried.filter(({ path }) => !isSidecar(path));
if (rootFormat === "ipa") return { all: visible, unattributed: 0 };
const all = visible.filter(({ path }) =>
roots.some((root) => isWithin(path, root)),
);

View File

@ -1,4 +1,5 @@
import { z } from "zod";
import { addressCoordinate } from "./native/addressCoordinate.js";
import type { JsonValue } from "./jsonValue.js";
@ -38,16 +39,6 @@ export const addressDistance = (start: string, end: string): number => {
);
};
const addressCoordinate = (
value: string,
): { readonly space: string; readonly offset: bigint } | null => {
const separator = value.lastIndexOf(":0x");
const space = separator < 0 ? "default" : value.slice(0, separator);
const offset = separator < 0 ? value : value.slice(separator + 1);
if (!/^0x[0-9a-f]+$/u.test(offset)) return null;
return { space, offset: BigInt(offset) };
};
const procedureMapSchema = z.record(z.string(), z.string());
const addressedNamesSchema = z.array(
z.object({ address: z.string(), name: z.string() }),
@ -128,9 +119,9 @@ export const functionBodySchema = z.discriminatedUnion("available", [
})
.superRefine((body, context) => {
let total = 0n;
const bounds = new Map<string, { start: bigint; end: bigint }>();
const closedSpaces = new Set<string>();
let previousSpace: string | null = null;
const bounds = new Map<string | null, { start: bigint; end: bigint }>();
const closedSpaces = new Set<string | null>();
let previousSpace: string | null | undefined;
for (const range of body.ranges) {
const start = addressCoordinate(range.start);
const end = addressCoordinate(range.end);
@ -146,7 +137,7 @@ export const functionBodySchema = z.discriminatedUnion("available", [
});
return;
}
if (previousSpace !== null && previousSpace !== start.space)
if (previousSpace !== undefined && previousSpace !== start.space)
closedSpaces.add(previousSpace);
const previous = bounds.get(start.space);
if (

View File

@ -339,7 +339,10 @@ const spawnOptions = (candidate: SpawnCandidate): t.ObjectExpression | null => {
const spawnArgvCount = (candidate: SpawnCandidate): number | null => {
if (candidate.method === "exec") return null;
const args = candidate.node.arguments[1];
return t.isArrayExpression(args) ? args.elements.length : null;
return t.isArrayExpression(args) &&
!args.elements.some((element) => t.isSpreadElement(element))
? args.elements.length
: null;
};
const objectHasProperty = (

View File

@ -429,7 +429,12 @@ export const addSourceMapDirectives = (
end === null
)
continue;
const commentSource = source.slice(start, end);
// The parser owns comment boundaries. Exclude only the closing block
// delimiter; an asterisk elsewhere remains part of the declared URL.
const commentSource = source.slice(
start,
comment.type === "CommentBlock" ? end - 2 : end,
);
if (
!(commentSource.startsWith("//") || commentSource.startsWith("/*")) ||
!(commentSource[2] === "#" || commentSource[2] === "@")
@ -449,9 +454,7 @@ export const addSourceMapDirectives = (
const urlOffset = commentSource.slice(equalsStart + 1).search(/\S/u);
if (urlOffset === -1) continue;
const declaredStart = equalsStart + 1 + urlOffset;
const terminator = commentSource
.slice(declaredStart)
.search(commentSource.startsWith("//") ? /\s/u : /[\s*]/u);
const terminator = commentSource.slice(declaredStart).search(/\s/u);
const declaredEnd =
terminator === -1 ? commentSource.length : declaredStart + terminator;
if (declaredEnd === declaredStart) continue;

View File

@ -72,10 +72,10 @@ describe("source map directives", () => {
it("preserves Unicode whitespace and URL terminator boundaries", () => {
const source =
"ğ/*@\u00a0sourceMappingURL\u2003=\ufeffapp.js.map* ignored */";
const declaredEnd = source.indexOf("* ignored");
const declaredEnd = source.indexOf(" ignored");
expect(analyzeJavaScriptStaticSource(source).source_map_urls).toEqual([
{
declared_url: "app.js.map",
declared_url: "app.js.map*",
location: {
start: { line: 1, column: 1 },
end: { line: 1, column: declaredEnd },
@ -84,6 +84,29 @@ describe("source map directives", () => {
]);
});
it.each([
["/*#", "app*part.js.map", " */"],
["/*@", "*part.js.map", "*/"],
["/*#", "app.js.map*", "*/"],
["/*#", "**part.js.map", " */"],
["/*#", "app.js.map", "*/"],
["//#", "app*part.js.map", ""],
])("preserves the URL and range in %s %s%s", (marker, url, suffix) => {
const directive = marker + " sourceMappingURL=" + url;
const result = analyzeJavaScriptStaticSource(
"export const value = 1;\n" + directive + suffix,
);
expect(result.source_map_urls).toEqual([
{
declared_url: url,
location: {
start: { line: 2, column: 0 },
end: { line: 2, column: directive.length },
},
},
]);
});
it("reports nothing when no directive is present", () => {
expect(sourceMapUrls("const value = 1;")).toEqual([]);
});

View File

@ -0,0 +1,11 @@
/** Parse canonical native offsets without discarding their address space. */
export const addressCoordinate = (
value: string,
): { readonly space: string | null; readonly offset: bigint } | null => {
const separator = value.lastIndexOf(":0x");
if (separator === 0) return null;
const space = separator < 0 ? null : value.slice(0, separator);
const offset = separator < 0 ? value : value.slice(separator + 1);
if (!/^0x[0-9a-f]+$/u.test(offset)) return null;
return { space, offset: BigInt(offset) };
};

View File

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { parseBytePattern } from "./bytePattern.js";
describe("masked byte patterns", () => {
it("preserves whole-byte wildcards and combines explicit bit masks", () => {
const parsed = parseBytePattern("48 8b\t??89", "ff f0 ff 0f");
if (!parsed.ok) throw parsed.error;
expect(parsed.value).toEqual({
bytes: [0x48, 0x8b, 0, 0x89],
masks: [255, 240, 0, 15],
});
const exact = parseBytePattern("Aa\u00a000");
if (!exact.ok) throw exact.error;
expect(exact.value).toEqual({ bytes: [170, 0], masks: [255, 255] });
});
it.each([
["", undefined, "pattern"],
["4", undefined, "pattern"],
["4?", undefined, "pattern"],
["gg", undefined, "pattern"],
["?? ??", undefined, "pattern"],
["ff", "00", "pattern"],
["ffff", "ff", "mask"],
["ff", "??", "mask"],
])("rejects invalid pattern %s with typed issues", (pattern, mask, field) => {
const parsed = parseBytePattern(pattern, mask);
if (parsed.ok) throw new Error("Expected a rejected pattern");
expect(parsed.error.issues).toMatchObject([
{ path: [field], reason: "invalid_value" },
]);
});
});

View File

@ -0,0 +1,125 @@
import { z } from "zod";
import { addressCoordinate } from "./addressCoordinate.js";
import { AnalysisInputError } from "../analysisErrorCore.js";
import { err, ok, type Result } from "../result.js";
/** Byte pairs, with whole-byte wildcards; explicit masks can select individual bits. */
export const bytePatternFields = {
pattern: z
.string()
.regex(/^([0-9a-fA-F]{2}|\?\?)(\s*([0-9a-fA-F]{2}|\?\?))*$/u),
mask: z
.string()
.regex(/^[0-9a-fA-F]{2}(\s*[0-9a-fA-F]{2})*$/u)
.optional(),
};
/** Parse once at the input boundary, retaining typed correction issues. */
export const parseBytePattern = (
pattern: string,
mask?: string,
): Result<{ bytes: number[]; masks: number[] }, AnalysisInputError> => {
const invalid = (field: string, message: string) =>
err(
new AnalysisInputError("search_bytes", undefined, [
{ path: [field], reason: "invalid_value", message },
]),
);
if (!bytePatternFields.pattern.safeParse(pattern).success)
return invalid("pattern", "Use hexadecimal byte pairs or ?? wildcards");
if (mask !== undefined && !bytePatternFields.mask.safeParse(mask).success)
return invalid("mask", "Use hexadecimal mask byte pairs");
const pairs = pattern.replace(/\s/gu, "").match(/../gu) ?? [];
const maskPairs = mask?.replace(/\s/gu, "").match(/../gu);
if (maskPairs && maskPairs.length !== pairs.length)
return invalid("mask", "Mask length must equal pattern length");
const bytes = pairs.map((pair) =>
pair === "??" ? 0 : Number.parseInt(pair, 16),
);
const masks = pairs.map((pair, index) =>
pair === "??" ? 0 : Number.parseInt(maskPairs?.[index] ?? "ff", 16),
);
if (!masks.some((value) => value !== 0))
return invalid("pattern", "Pattern must constrain at least one bit");
return ok({ bytes, masks });
};
/** Shared semantic validation for public and provider inputs. */
export const validateBytePattern = (
value: { pattern: string; mask?: string | undefined },
context: z.RefinementCtx,
): void => {
const parsed = parseBytePattern(value.pattern, value.mask);
if (!parsed.ok)
for (const issue of parsed.error.issues)
context.addIssue({
code: "custom",
path: [...issue.path],
message: issue.message ?? "Invalid byte pattern",
});
};
const coverageBlock = z
.strictObject({
segment: z.string(),
start: z.string(),
end: z.string(),
bytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
})
.superRefine((value, context) => {
const start = addressCoordinate(value.start);
const end = addressCoordinate(value.end);
if (
start === null ||
end === null ||
start.space !== end.space ||
start.offset > end.offset ||
end.offset - start.offset + 1n !== BigInt(value.bytes)
)
context.addIssue({
code: "custom",
message:
"Byte coverage must equal its inclusive address range in one space",
});
});
/** Inclusive page coverage; matches never span memory blocks. */
export const byteSearchResultSchema = z
.strictObject({
matches: z
.array(
z.strictObject({
address: z.string(),
segment: z.string(),
file_offset: z
.number()
.int()
.nonnegative()
.max(Number.MAX_SAFE_INTEGER)
.nullable(),
procedure: z
.strictObject({ address: z.string(), name: z.string() })
.nullable(),
}),
)
.max(1000),
searched: z.strictObject({
blocks: z.array(coverageBlock),
bytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
skipped_uninitialized: z.array(coverageBlock),
skipped_not_loaded: z.array(coverageBlock),
}),
complete: z.boolean(),
next_cursor: z.string().min(1).nullable(),
})
.superRefine((value, context) => {
if (
value.complete !== (value.next_cursor === null) ||
value.searched.bytes !==
value.searched.blocks.reduce((sum, block) => sum + block.bytes, 0)
)
context.addIssue({
code: "custom",
message: "Byte search coverage and continuation disagree",
});
});

View File

@ -1,4 +1,5 @@
import { z } from "zod";
import { providerSchema } from "../evidence.js";
import {
compareUnicodeCodePoints,
@ -115,6 +116,7 @@ export const nativeMetadataEvidenceSchema = z.strictObject({
"decompiler",
]),
description: z.string().min(1),
provider: providerSchema.optional(),
location: nativeMetadataLocationSchema,
artifact_path: z.string().min(1).nullable().default(null),
artifact_sha256: z

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);
});
});

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),

View File

@ -143,3 +143,13 @@ export const completeReconstructionCoverageData = () => {
],
});
};
/** Complete workspace with its first verifier result and boundary narrowed once. */
export const completeReconstructionCoverageFixture = () => {
const workspace = completeReconstructionCoverageData();
const result = workspace.verifier_results[0];
const boundary = workspace.boundaries[0];
if (result === undefined || boundary === undefined)
throw new Error("Expected complete reconstruction coverage fixture");
return { workspace, result, boundary };
};

Some files were not shown because too many files have changed in this diff Show More