Compare commits

..
Author SHA1 Message Date
morluto a8adc2c6b1 fix(setup): retain Codex per-tool approval maps 2026-10-14 01:10:13 +08:00
REA Maintainers 7b90bc5a67 fix(setup): reconcile client plans and native registration fields 2026-10-13 22:55:53 +08:00
REA Maintainers 1f1bb95d53 fix(setup): align activation schemas and approval disclosure 2026-10-13 21:32:35 +08:00
morluto 3370bab50b style(setup): format retained field validation 2026-10-13 20:20:42 +08:00
morluto 69582df9c3 fix(setup): validate retained Codex registration fields 2026-10-13 20:10:19 +08:00
xeropresence 303008f35d fix(setup): drop ZCode's enable: false from retained entries
Main's ZCode registration treats a native enable: false as stale and expects setup to repair it. Entry retention kept that switch, so setup reported unchanged while doctor still reported stale.
2026-10-12 21:49:21 -04:00
Cursor Agentandxeropresence 55246a6425 fix(setup): match retained fields to the client that writes them
Codex drops a tools array that its stdio config rejects, and the Harness approval plan lists the Cordis launcher keys the writer replaces.
2026-10-12 21:37:33 -04:00
Cursor Agentandxeropresence 4eb3b5335a fix(setup): describe the inspected OpenCode registration dialect
The approval plan uses the dialect configuration inspection parsed, so a native OpenCode server table is not described as rewriting enabled.
2026-10-12 21:37:33 -04:00
Cursor Agentandxeropresence fb3fa1dc5f refactor(setup): accept retained fields from the registration schema
Setup keeps a registration field only when the schema doctor parses accepts it, and the OpenCode legacy disclosure follows that client's dialect.
2026-10-12 21:37:33 -04:00
Cursor Agentandxeropresence ecd92970a4 fix(setup): retain only registration fields doctor can parse
Setup kept malformed startup_timeout_sec and tools values that doctor
rejects, and the approval plan restated retention rules by client name.
Both now use the registration schema and the entry the writer builds.
2026-10-12 21:37:33 -04:00
Cursor Agentandxeropresence ff34e7aeff fix(setup): drop non-boolean activation switches from retained entries
A string such as enabled: "false" was kept and then accepted as the desired
entry, so setup could report success while doctor rejected the registration.
Those values are removed with the other managed switches.
2026-10-12 21:35:28 -04:00
Cursor Agentandxeropresence 53ba7e7b31 fix(setup): drop stale Qwen tcp endpoints and name rewritten entry keys
Qwen prefers a tcp websocket endpoint over command, so a command entry that
still has tcp stays remote. Setup now removes that key. The approval plan
names startup_timeout_sec and Copilot tools when setup replaces them.
2026-10-12 21:35:28 -04:00
Cursor Agentandxeropresence 0cd9f47ff1 fix(setup): disclose remote-key removal in the approval plan
A local command entry still drops stale remote-server keys and off switches.
The plan now says those fields are removed, and that Grok Build keeps its
entry disabled flag.
2026-10-12 21:35:28 -04:00
Cursor Agentandxeropresence 502e665212 fix(setup): drop stale Codex oauth_resource and disclose remote replacement
A command entry that still carries oauth_resource is rejected by Codex for
stdio. Setup now treats that key as remote and removes it. The approval plan
keeps other fields only for a local command entry and says a remote entry's
other fields are replaced.
2026-10-12 21:35:28 -04:00
xeropresence b478a8b011 fix(setup): preserve user fields on an existing REA client entry 2026-10-12 21:35:28 -04:00
185 changed files with 1851 additions and 7987 deletions
@@ -175,9 +175,7 @@ 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. 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 JAR. These are separate routes. See the
[NativeAOT guide](https://github.com/morluto/rea/blob/main/docs/ghidra-nativeaot.md)
for setup, supported layouts, and host restrictions. Preserve binary identity
and distinguish derived metadata from Ghidra database observations. Recovered
@@ -249,9 +247,3 @@ resolution is explicit; missing references and size mismatch remain visible.
Other payloads are opaque, and this operation does not decode images or simulate
Windows resource loading. Consult the budgets and supported tree profile in
[offline binary diagnostics](https://github.com/morluto/rea/blob/main/docs/binary-diagnostics.md).
For exact core-module import/export differences, use `compare_wasm_interfaces`
with two complete inspection Evidence records (or current-session retained
references) and explicit scope. Preserve unknown types/duplicate names; static
interface equality is not runtime compatibility. CLI accepts complete inline
Evidence in `rea compare-wasm-interfaces input.json --json`.
+12 -163
View File
@@ -28,7 +28,6 @@ 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;
@@ -142,7 +141,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
"set_addresses_names",
"inspect_native_load_image",
"read_bytes",
"search_bytes",
"address_to_file_offset",
"ping",
"shutdown",
@@ -380,7 +378,6 @@ 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);
@@ -687,160 +684,6 @@ 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);
@@ -848,12 +691,18 @@ 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));
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));
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));
JsonObject result = new JsonObject();
result.addProperty("address", canonicalAddress(address));
result.addProperty("file_offset", fileOffset);
+10 -31
View File
@@ -11,12 +11,6 @@ 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):
@@ -57,27 +51,12 @@ 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]) + ".")
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) + ".")
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.")
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_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
from pwnlib.elf.datatypes import elf_prstatus_amd64, elf_siginfo_64, user_regs_struct_amd64
try:
image = ELFFile(io.BytesIO(content))
header = image.header
@@ -128,17 +107,17 @@ def inspect_core(path, cache):
if owner != b"CORE\0":
continue
if note.n_type == "NT_PRSTATUS":
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)
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)
registers = []
for name, kind in registers_type._fields_:
relative = prstatus_type.pr_reg.offset + getattr(registers_type, name).offset
for name, kind in user_regs_struct_amd64._fields_:
relative = elf_prstatus_amd64.pr_reg.offset + getattr(user_regs_struct_amd64, 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 " + profile_label + " NT_SIGINFO descriptor is truncated.")
raise CoreFailure("format", "Linux amd64 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
@@ -146,7 +125,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": architecture, "target_execution": "not-performed", "live_process_identity": "unknown",
return {"format": "elf-core", "architecture": "x86_64-little-endian", "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.",
-12
View File
@@ -129,18 +129,6 @@ 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),
+5 -14
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, parser-comparison, and managed scenarios assess a closed set of
The packaged desktop and parser-comparison scenarios now 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,24 +475,15 @@ 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, browser,
claims, not unrestricted factual correctness. Native, managed, browser,
navigation-context, and address-context scenarios have no factual rubric and
remain `not_assessed`.
remain `not_assessed`; the managed workflow requires both artifact and member
inspection to answer its type and entry-point question.
The closed factual scenarios request complete producer Evidence on the initial
inspection or analysis so their source selectors can authenticate all configured facts.
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.
+7 -25
View File
@@ -17,14 +17,10 @@ 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.
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
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
assembly should use `inspect_managed_artifact`; it is not NativeAOT.
## Build and configure
@@ -61,9 +57,7 @@ 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. Without
the optional adapter, each recovered type's `type` is `null`, because no Ghidra
DataType exists; select it by `address`.
not mean the metadata parser failed or the executable failed to load.
Pass a returned MethodTable address to `inspect_native_data_type`. A direct PE
query also derives the snapshot metadata from the authenticated Ghidra session
@@ -73,12 +67,8 @@ 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: 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.
merge frozen literals into Ghidra's database string inventory. 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:
@@ -139,11 +129,3 @@ 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.
-29
View File
@@ -198,32 +198,3 @@ 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.
+20 -9
View File
@@ -66,6 +66,21 @@ copy's path and the restore failure.
Setup also retains valid environment overrides in REA's existing server entry,
such as `REA_MCP_INPUT_SCHEMA_PROFILE` and `REA_MCP_MAX_RESPONSE_BYTES`.
Detected provider settings replace only matching environment keys.
Other fields you added to that entry, such as OMP's `timeout` or Codex's
`tool_timeout_sec`, are kept. Setup rewrites only the launch keys (`command`,
`args`, `type` or `transport`, and the environment map) and REA's
client-specific keys. It removes remote-server keys such as `url`, `headers`,
`oauth_resource`, and `tcp` that contradict the local command, and an entry-level
`enabled: false`, ZCode's `enable: false`, or (outside Grok Build)
`disabled: true`, so the registration is active. A registration field that
does not parse, including a non-boolean `enabled` or `disabled` and a
malformed `startup_timeout_sec` or `tools`, is
removed too. A `tools` value is removed except on GitHub Copilot CLI, which
replaces it with `["*"]`; Codex rejects a `tools` sequence. An existing entry
without a `command` is a remote server and is replaced whole. DeepSeek Harness
keeps other fields on the existing Cordis server and replaces `serverName`,
`transport`, `command`, and `args`. A legacy OpenCode entry moved into the
native `mcp.servers` table keeps only its environment.
Setup and doctor report a malformed REA environment map or invalid runtime
setting before setup writes files. Repair the named setting in the client
configuration; setup does not silently discard the rest of the map.
@@ -279,7 +294,6 @@ 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` |
@@ -291,8 +305,8 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
| ZCode | `zcode` |
| DeepSeek Harness | `dsh` |
`dsh` and `kimi` require a release containing them. The registry check above does
not cover them.
`dsh` is main-only until a release after 6.4.0. The registry check above does
not cover it.
See [client configuration and troubleshooting](client-configuration.md) for
configuration paths, profiles, trust policies, and schema-limit workarounds.
@@ -311,12 +325,9 @@ 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 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](windows-ghidra-p0.md).
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](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
+2 -3
View File
@@ -233,9 +233,8 @@ 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. For x64 PE NativeAOT, the read-only
parser also runs there; the [NativeAOT guide](ghidra-nativeaot.md) lists what a
Windows host covers.
applications and DLLs for static analysis. This does not establish Windows
NativeAOT metadata-recovery coverage.
For NativeAOT analysis, preserve the selected native artifact's path and digest
and report recovered type data with its returned provenance. Linking those types
+9 -14
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 and `isError` flag, so only those count against the budget. Response budget settings are captured at startup;
representation, so only that text counts 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,11 +271,9 @@ 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 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
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
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
@@ -604,12 +602,8 @@ 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 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.
preserving complete nested fields and validation rules. 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.
@@ -656,7 +650,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`,
`evaluate_reconstruction_coverage`, `project_managed_application_graph`,
`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,
@@ -666,7 +660,8 @@ 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. Client verification
valid JSON Schema that still accepts every canonical example, and hold the
set of six reduced presentations as contracts evolve. 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.
-14
View File
@@ -549,17 +549,3 @@ 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.
+4 -8
View File
@@ -1,10 +1,7 @@
# Recorded crash evidence
`inspect_recorded_crash` / `rea inspect-recorded-crash` inspect a caller-selected
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.
Linux ELF64 x86-64 little-endian core independently of an active binary target.
```sh
export REA_PWNTOOLS_PYTHON=/absolute/existing/venv/bin/python
@@ -13,8 +10,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 and aarch64 ctypes provide parsing and ABI layout.
The adapter adds bounds, representation preservation and lifecycle ownership.
pwntools' original Linux amd64 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.
@@ -30,8 +27,7 @@ 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 ABI register set for its architecture (27 registers for amd64,
34 for AArch64), with names bound to their original ABI offsets.
complete 27-register ABI set, 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
@@ -1,10 +0,0 @@
# 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.
@@ -1,61 +0,0 @@
# 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
@@ -1,8 +0,0 @@
# 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.
@@ -1,47 +0,0 @@
# Verification
Verified base: a2d8b94dd50dab8d966239dd63ab84718aa8b4b4 (fetched main).
The change was reapplied without conflicts and relevant checks rerun.
- Focused existing/new parser, provider, service, contract and CLI/MCP tests:
8 files / 56 tests passed.
- After the final partial-uniqueness correction, reran service and compiled
CLI/MCP boundary tests: 2 files / 5 tests passed.
- `npm run check`: typecheck, lint, formatting, knip, module boundaries and
architecture guards passed. Lint reports warnings; no errors.
- `npm run docs:generate`, `npm run docs:check` and
`npm run verify:test-discovery` passed; every test file was discovered exactly once.
- Real WABT 1.0.42 comparison through compiled CLI and stdio MCP passed:
equivalent padded LEB128 original encodings, function signatures, all admitted
kinds, custom memory page size, escaped/BOM names, empty interfaces, explicit
scope, duplicate imports and unadmitted exception reference signatures.
- Existing real WABT artifact verifier passed after correcting its stale inline
partial-observation assumption to resolve the MCP retained reference from the
Evidence bundle and authenticate the original record.
## Independent review
Three findings repaired at their owning boundary:
1. Memory page size omitted: now preserved, default 65536 canonicalized.
2. Shared signature expansion undercounted: charge every retained occurrence.
Reviewer independently measured the 1000-params/10000-exports case: original
262956-byte WAT produced 61824014-byte JSON before correction. It now retains
1949 observations in a partial facet, with 12046676-byte JSON and an explicit
capacity issue. Original WAT stays available; incomplete coverage prevents
absence and uniqueness claims.
3. Partial singleton uniqueness: comparison now returns unknown whenever either
selected inventory is incomplete, including matched names.
Final independent source review reported no actionable findings. Review does not
replace the executed tests or real-tool evidence.
## Scope and measurement limits
On the original implementation base f254f5a, the complete comparison verifier reported 7.98 seconds elapsed, 6.56 user and
3.13 system seconds, and 429441024 bytes maximum RSS via macOS `/usr/bin/time -l`.
These are command-accounting measurements, not aggregate concurrent process-family
memory or model tokens. Token usage and full process-family peak are unmeasured.
Real-tool verification covers macOS arm64 only; Linux/Windows remain unverified.
No module was instantiated/executed and no source was fetched. Static equality
is not runtime compatibility. Full CI is separate from this local verification record.
-6
View File
@@ -646,12 +646,6 @@ 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`,
-3
View File
@@ -1,7 +1,4 @@
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
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
2 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
3 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
4 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
-48
View File
@@ -13,11 +13,6 @@ 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
@@ -84,46 +79,3 @@ Both CLI and MCP use the same typed port and application service. MCP publishes
SDK-compatible schemas and records Evidence for `get_evidence_bundle` readback.
See [WABT provenance](https://github.com/morluto/rea/blob/main/third_party/wabt/README.md) and
[testing](testing.md#offline-wasm-artifacts) for real-tool verification.
## Compare core-module interfaces
Inspection adds producer-owned typed `interface` facts while keeping exact WAT
import/export forms. Function/tag signatures resolve type indices; table/memory
limits, memory page size and global value type/mutability remain explicit.
Unadmitted types (including GC reference signatures) retain null signatures and
issues. Exact decoded names retain escapes, empty names, BOM and punctuation.
This facet is absent in historical Evidence that did not examine typed interfaces.
`compare_wasm_interfaces` accepts two complete `inspect_wasm_artifact` Evidence
records, or exact retained references in the current MCP session, and required
`scope: "imports" | "exports" | "both"`. It reads no files and needs no active
WABT toolchain. CLI uses portable inline Evidence in one JSON document:
```bash
rea compare-wasm-interfaces comparison-input.json --json
```
```json
{
"left": { "kind": "retained-evidence", "evidence_id": "ev_<left-digest>" },
"right": { "kind": "retained-evidence", "evidence_id": "ev_<right-digest>" },
"scope": "both"
}
```
The references above are MCP-only; put complete Evidence objects in the CLI
input. Resolve summary delivery to its retained complete parent before comparing.
Results retain both input Evidence IDs, artifact paths/digests/lengths and every
selected occurrence. Unique names are added, removed, changed or unchanged;
duplicate names and unsupported signatures are unknown. Incomplete or historical
inventories never establish absence. Imports group by module/name, exports by
name, so a change of kind is visible. Output ordering is deterministic.
Static type equality does not establish runtime compatibility, ABI compatibility
or linking success. Comparison covers core modules; WebAssembly components and
canonical ABI relationships belong to their separate inspection workflow.
After building, run `REA_WABT_BIN_DIRECTORY=/absolute/wabt/bin node
scripts/verify-wasm-interface-comparison.mjs` for original-module comparison
through the compiled CLI and stdio MCP. It uses owned fixtures and WABT 1.0.42;
no WASM module is instantiated or executed.
+2 -4
View File
@@ -29,10 +29,8 @@ 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: 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).
database mutation. A real Windows-host NativeAOT Ghidra run is still required
to establish the complete host workflow.
This boundary does not establish general Windows feature parity. Ghidra GUI
state, annotations, other target architectures and formats, and Hopper-only
-1
View File
@@ -269,7 +269,6 @@
"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",
+1 -3
View File
@@ -213,9 +213,7 @@ export const providerCatalog = (sources) => {
},
{
identity: sources.wasmProvider.WABT_PROVIDER_IDENTITY,
contracts: sources.wasmContracts.WASM_TOOL_CONTRACTS.filter(
({ kind }) => kind === "artifact-provider",
),
contracts: sources.wasmContracts.WASM_TOOL_CONTRACTS,
},
{
identity: sources.evmProvider.EVMOLE_PROVIDER_IDENTITY,
+1 -9
View File
@@ -1,5 +1,4 @@
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import {
chmod,
copyFile,
@@ -260,7 +259,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 inspection or 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 application 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;
@@ -441,13 +440,6 @@ 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"),
}
: {}),
};
}
-88
View File
@@ -114,22 +114,6 @@ 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(
@@ -227,69 +211,6 @@ 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,
@@ -310,15 +231,6 @@ 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);
+1 -17
View File
@@ -310,23 +310,7 @@ try {
}),
);
assert.equal(error.category, "invalid_input");
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,
),
);
const failed = parseEvidence(error.details.partial_observation);
assert.equal(failed.subject.digest.sha256, sha256(invalidBytes));
assert.equal(
failed.normalized_result.validation,
@@ -1,208 +0,0 @@
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 });
}
-24
View File
@@ -89,30 +89,6 @@ 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
-62
View File
@@ -1127,68 +1127,6 @@ 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({
+2 -36
View File
@@ -71,7 +71,6 @@ import {
parseBugreportCopiedPath,
parseDumpsysPackageOutput,
parseFeatureListOutput,
packageChangeFailureReason,
parsePackageChangeOutput,
parseProcessListOutput,
parsePngDimensions,
@@ -249,31 +248,6 @@ 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,
@@ -1780,11 +1754,7 @@ export class AdbProvider {
{ timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES },
signal,
);
if (!installed.ok)
return (
packageChangeRefusal(operation, input.serial, installed.error) ??
installed
);
if (!installed.ok) return installed;
const verdict = parsePackageChangeOutput(installed.value.stdout);
if (verdict.status === "failure")
return err(
@@ -1833,11 +1803,7 @@ export class AdbProvider {
},
signal,
);
if (!uninstalled.ok)
return (
packageChangeRefusal(operation, input.serial, uninstalled.error) ??
uninstalled
);
if (!uninstalled.ok) return uninstalled;
const verdict = parsePackageChangeOutput(uninstalled.value.stdout);
if (verdict.status === "failure")
return err(
+2 -7
View File
@@ -293,10 +293,6 @@ 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.
@@ -309,11 +305,10 @@ 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:
packageChangeFailureReason(text) ??
(text === "" ? null : text.slice(0, 512)),
reason: failure?.[1] ?? (text === "" ? null : text.slice(0, 512)),
};
};
@@ -1,34 +0,0 @@
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 });
}
});
});
+1 -6
View File
@@ -162,12 +162,6 @@ 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"))
@@ -179,6 +173,7 @@ export const parseBinaryTarget = async (
kind: "database",
format: "analysis-database",
});
const artifactFormat = await detectArtifactFormat(path, handle, signal);
if (artifactFormat !== undefined) {
const identity = {
path,
+143 -12
View File
@@ -23,7 +23,9 @@ import {
} from "jsonc-parser";
import { z } from "zod";
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { registrationFieldAccepted } from "./ClientRegistrationShape.js";
import type { SetupClient } from "./SupportedClients.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
@@ -110,12 +112,9 @@ 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 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 {};
// rejected instead of registering into a document Pi cannot load.
if (format === "pi")
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 {};
@@ -1449,13 +1448,6 @@ 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 {
@@ -1510,6 +1502,145 @@ export const clientRegistrationEntry = (
}
};
/**
* Launcher and transport keys REA owns in its entry. A key absent from REA's
* new entry is removed, so a stale `type: "http"` cannot outlive the stdio
* registration and an emptied environment map is not left behind.
*/
const REGISTRATION_LAUNCHER_KEYS: Readonly<Record<string, true>> = {
command: true,
args: true,
env: true,
environment: true,
type: true,
transport: true,
};
/** Remote-server keys that contradict the local command entry REA writes. */
const REGISTRATION_REMOTE_KEYS: Readonly<Record<string, true>> = {
url: true,
httpUrl: true,
serverUrl: true,
headers: true,
http_headers: true,
env_http_headers: true,
bearer_token_env_var: true,
oauth: true,
// Codex serializes `--oauth-resource` as `oauth_resource` and rejects it on
// a command entry (`oauth_resource is not supported for stdio`).
oauth_resource: true,
// Qwen treats `tcp` as a websocket endpoint and prefers it over `command`,
// so a command entry that still has `tcp` stays remote.
tcp: true,
};
/** Grok registration status ignores the entry `disabled` flag. */
const keepsGrokDisabledFlag = (dialect: ClientRegistrationDialect): boolean =>
dialect === "grok";
/**
* Copilot rewrites `tools` as a string array. Other clients do not: Codex
* expects a map and rejects a sequence (`invalid type: sequence, expected a map`).
*/
const keepsCopilotTools = (dialect: ClientRegistrationDialect): boolean =>
dialect === "copilot_cli";
/**
* Whether setup replaces or removes an existing entry key. Setup registers an
* active server, so entry-level switches that turn it off are removed too,
* including ZCode's native `enable: false`. A registration-status field that
* does not parse is removed as well, using the same schemas doctor uses, so
* setup cannot report success for a value doctor will reject. Grok Build's
* boolean entry `disabled` is kept.
*/
const registrationManagedKey = (
dialect: ClientRegistrationDialect,
key: string,
value: unknown,
): boolean =>
Object.hasOwn(REGISTRATION_LAUNCHER_KEYS, key) ||
Object.hasOwn(REGISTRATION_REMOTE_KEYS, key) ||
!registrationFieldAccepted(dialect, key, value) ||
(key === "tools" && dialect !== "toml" && !keepsCopilotTools(dialect)) ||
(key === "enabled" && value === false) ||
(key === "enable" && value === false && dialect === "zcode") ||
(key === "disabled" && value === true && !keepsGrokDisabledFlag(dialect));
/**
* Apply REA's new entry over an existing local command entry. REA's keys take
* their new values in place, and every other key the user added (for example
* OMP `timeout` or Codex `tool_timeout_sec`) is kept. An existing entry without
* a `command` is a remote server, which REA replaces whole.
*/
export const retainClientRegistrationFields = (
dialect: ClientRegistrationDialect,
existing: unknown,
registration: Readonly<Record<string, unknown>>,
): Record<string, unknown> => {
const current = objectSchema.safeParse(existing);
if (!current.success || !Object.hasOwn(current.data, "command"))
return { ...registration };
const retained = Object.fromEntries(
Object.entries(current.data).flatMap(([key, value]): [string, unknown][] =>
Object.hasOwn(registration, key)
? [[key, registration[key]]]
: registrationManagedKey(dialect, key, value)
? []
: [[key, value]],
),
);
return { ...retained, ...registration };
};
/** Codex and Grok Build always receive REA's startup timeout. */
export const applyRegistrationStartupTimeout = (
clientName: string,
registration: Readonly<Record<string, unknown>>,
): Record<string, unknown> =>
clientName === "codex" || clientName === "grok_build"
? {
...registration,
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
}
: { ...registration };
/**
* Approval-plan text for the retention rules above. Rewritten keys are the
* ones the fresh entry sets beyond its launcher, so the sentence cannot name
* a field the writer does not replace.
*/
export const describeClientRegistrationRetention = (
clientName: string,
dialect: ClientRegistrationDialect,
): string => {
const replaced = Object.entries(
applyRegistrationStartupTimeout(
clientName,
clientRegistrationEntry(dialect, ["rea", "mcp"], {}),
),
)
.filter(([key]) => !Object.hasOwn(REGISTRATION_LAUNCHER_KEYS, key))
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => `${key} with ${JSON.stringify(value)}`);
const removed = keepsGrokDisabledFlag(dialect)
? "Remote-server keys, an enabled: false switch, and registration fields that do not parse are removed. A boolean disabled value is kept."
: "Remote-server keys, switches that turn the server off, and registration fields that do not parse are removed.";
const tools = keepsCopilotTools(dialect)
? ""
: dialect === "toml"
? " Invalid Codex tools values are removed; valid per-tool approval maps are kept."
: " A tools value is removed.";
const replacement =
replaced.length === 0
? `${removed}${tools}`
: `Setup replaces ${replaced.join(" and ")}. ${removed}${tools}`;
const legacy =
dialect === "opencode_v2"
? " A legacy OpenCode entry moved into the native server table keeps only its environment."
: "";
return `On an existing local command entry, other user fields are kept. ${replacement} When that entry is remote, its other fields are replaced.${legacy}`;
};
/** Parse one Gemini policy at its source boundary before consolidating scopes. */
export const parseGeminiMcpPolicy = (parsed: ClientConfigurationDocument) =>
z
@@ -0,0 +1,67 @@
import { z } from "zod";
import type { ClientRegistrationDialect } from "./ClientConfigurationDocument.js";
/**
* Registration-status fields doctor parses on every client. User fields
* outside this schema, such as OMP `timeout`, are not registration-status
* fields. Setup accepts a retained value only when this schema does.
*/
export const registrationSchema = z
.object({
command: z.string().min(1),
args: z.array(z.string()).default([]),
startup_timeout_sec: z.number().positive().optional(),
type: z.string().optional(),
transport: z.string().optional(),
serverName: z.string().optional(),
tools: z.array(z.string()).optional(),
disabled: z.boolean().optional(),
enabled: z.boolean().optional(),
})
.passthrough();
const codexRegistrationSchema = registrationSchema.extend({
tool_timeout_sec: z.number().optional(),
tools: z
.record(
z.string(),
z
.object({
approval_mode: z
.enum(["auto", "prompt", "writes", "approve"])
.optional(),
output_token_limit: z.number().int().positive().optional(),
})
.strict(),
)
.optional(),
required: z.boolean().optional(),
cwd: z.string().optional(),
env_vars: z.array(z.string()).optional(),
enabled_tools: z.array(z.string()).optional(),
disabled_tools: z.array(z.string()).optional(),
});
const zcodeRegistrationSchema = registrationSchema.extend({
enable: z.boolean().optional(),
});
/** Client-specific parsing is shared by retention and registration diagnostics. */
export const registrationSchemaFor = (dialect: ClientRegistrationDialect) =>
dialect === "toml"
? codexRegistrationSchema
: dialect === "zcode"
? zcodeRegistrationSchema
: registrationSchema;
/** Whether a retained field satisfies the selected client's known contract. */
export const registrationFieldAccepted = (
dialect: ClientRegistrationDialect,
key: string,
value: unknown,
): boolean => {
const shape: Readonly<Record<string, z.ZodType>> =
registrationSchemaFor(dialect).shape;
const field = Object.hasOwn(shape, key) ? shape[key] : undefined;
return field === undefined || field.safeParse(value).success;
};
+15 -41
View File
@@ -14,6 +14,10 @@ import { resolve } from "node:path";
import { z } from "zod";
import {
registrationSchema,
registrationSchemaFor,
} from "./ClientRegistrationShape.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
import {
@@ -61,22 +65,6 @@ export type UnhealthyClientRegistrationStatus = Exclude<
{ readonly state: "aligned" }
>;
const registrationSchema = z
.object({
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(),
tools: z.array(z.string()).optional(),
disabled: z.boolean().optional(),
enabled: z.boolean().optional(),
})
.passthrough();
interface ClientRegistrationReadOptions {
readonly platform?: NodeJS.Platform;
readonly environment?: NodeJS.ProcessEnv;
@@ -260,21 +248,6 @@ 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.
@@ -283,6 +256,13 @@ 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" ||
@@ -340,13 +320,9 @@ const parseRegistration = (
args,
});
}
const registration = registrationSchema.parse(value);
if (
client.format === "kimi" &&
registration.transport !== undefined &&
registration.transport !== "stdio"
)
throw new TypeError("Expected an stdio registration");
const registration = registrationSchemaFor(client.format ?? "json").parse(
value,
);
if (
(client.format === "vscode" || client.format === "copilot_cli") &&
registration.type !== "stdio"
@@ -355,9 +331,7 @@ const parseRegistration = (
if (client.format === "commandcode" && registration.transport !== "stdio")
throw new TypeError("Expected an stdio registration");
if (
(client.format === "kimi" ||
client.format === "omp" ||
client.format === "pi") &&
(client.format === "omp" || client.format === "pi") &&
registration.type !== undefined &&
registration.type !== "stdio"
)
-1
View File
@@ -60,7 +60,6 @@ type DirectAnalysisTool =
| "annotate_native_function"
| "inspect_native_load_image"
| "read_bytes"
| "search_bytes"
| "address_to_file_offset"
| "binary_overview"
| "procedure_pseudo_code"
+8 -32
View File
@@ -74,7 +74,7 @@ describe("doctor", () => {
),
).toEqual({
node: `Install ${SUPPORTED_NODE_VERSION_PROSE}.`,
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.",
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.",
hopper: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",
target: "Supply a readable local app or program path.",
});
@@ -418,9 +418,10 @@ describe("doctor scoped readiness", () => {
platform: "linux",
linuxDistribution: () =>
Promise.resolve({
id: "nixos",
packageFamily: undefined,
hopperSetupSupported: false,
id: "debian",
versionId: "13",
packageFamily: "deb",
supported: false,
}),
clientRegistrations: () =>
Promise.resolve([
@@ -450,31 +451,6 @@ 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",
@@ -526,7 +502,7 @@ describe("doctor Linux and Hopper discovery", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
hopperSetupSupported: true,
supported: true,
}),
configuredHopperPath: path,
executable: (candidate) => Promise.resolve(candidate === path),
@@ -546,7 +522,7 @@ describe("doctor Linux and Hopper discovery", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
hopperSetupSupported: true,
supported: true,
}),
linuxDemoRuntimeCheck: () =>
Promise.resolve({
@@ -578,7 +554,7 @@ describe("doctor Linux and Hopper discovery", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
hopperSetupSupported: true,
supported: true,
}),
executable: (candidate) => Promise.resolve(candidate === path),
supportedLinuxHopper: () => Promise.resolve(false),
+3 -3
View File
@@ -2,7 +2,7 @@ import {
SUPPORTED_NODE_VERSION_PROSE,
supportsNodeVersion,
} from "../domain/runtimeVersion.js";
import { linuxHopperLauncherPath, supportsReaHost } from "./LinuxHopper.js";
import { linuxHopperLauncherPath } 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" && supportsReaHost(linuxDistribution)) ||
(host.platform === "linux" && linuxDistribution?.supported === true) ||
(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+, 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.",
"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.",
classification: "unsupported_host",
});
};
+16 -10
View File
@@ -8,7 +8,6 @@ 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 &&
@@ -296,15 +295,10 @@ export class DshCordisDocument {
carried = true;
return [this.#entry(desired, invalidated)] as typeof inserted.items;
});
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);
replacements.set(
op.source,
inserted.items.length === 0 ? undefined : copy,
);
}
const output = this.#yaml.document.clone();
const outputRoot = output.contents;
@@ -410,6 +404,18 @@ export const serializeDshDocument = (
throw new ConfigurationError("Missing Cordis edit plan");
return document.cordis.source.serialize(document.cordis.desired);
};
/** Approval-plan text for the launcher keys `DshCordisDocument` replaces. */
export const describeDshRegistrationRetention = (
command: readonly string[],
): string => {
const entry = dshClientRegistrationEntry(command);
const replaced = [...LAUNCHER_KEYS]
.filter((key) => Object.hasOwn(entry, key))
.sort((left, right) => left.localeCompare(right))
.map((key) => `${key} with ${JSON.stringify(entry[key])}`);
return `Other fields on the existing Cordis server are kept, except the row-level disabled flag is removed to activate the registration and name is set to ${JSON.stringify(DSH_CLIENT_PACKAGE)}. Setup replaces ${replaced.join(", ")}. Environment overrides are merged.`;
};
export const dshClientRegistrationEntry = (
command: readonly string[],
environment: Readonly<Record<string, string>> = {},
+6 -31
View File
@@ -7,7 +7,6 @@ import {
linuxPackageManagerCommands,
linuxSharedLibrariesAvailable,
parseLinuxDistribution,
supportsReaHost,
type LinuxDistribution,
type LinuxHopperDownload,
type LinuxHopperInstallHost,
@@ -20,7 +19,7 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
hopperSetupSupported: true,
supported: true,
};
archive = new Uint8Array([1, 2, 3]);
archiveOk = true;
@@ -67,7 +66,7 @@ describe("Linux Hopper host classification", () => {
(document, family) => {
expect(parseLinuxDistribution(document)).toMatchObject({
packageFamily: family,
hopperSetupSupported: true,
supported: true,
});
},
);
@@ -100,32 +99,8 @@ describe("Linux Hopper host classification", () => {
"ID=garuda\nID_LIKE=arch\n",
"ID=omarchy\n",
"ID=omarchy\nID_LIKE=debian\n",
])(
"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);
])("rejects unsupported vendor/version combinations", (document) => {
expect(parseLinuxDistribution(document).supported).toBe(false);
});
});
@@ -191,7 +166,7 @@ describe("Linux Hopper installation", () => {
host.distributionValue = {
id: family,
packageFamily: family,
hopperSetupSupported: true,
supported: true,
};
const result = await installFixture(host);
expect(result).toEqual({
@@ -219,7 +194,7 @@ describe("Linux Hopper installation", () => {
host.distributionValue = {
id: "debian",
packageFamily: "deb",
hopperSetupSupported: false,
supported: false,
};
if (scenario === "package request failure") host.archiveOk = false;
if (scenario === "checksum mismatch") host.archive = new Uint8Array([9]);
+12 -31
View File
@@ -76,35 +76,19 @@ interface LinuxDistributionIdentity {
readonly versionId?: string;
}
/**
* 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.
*/
/** Parsed Linux distribution information used for support and package selection. */
export type LinuxDistribution = LinuxDistributionIdentity &
(
| {
readonly packageFamily: LinuxPackageFamily;
readonly hopperSetupSupported: true;
readonly supported: true;
}
| {
readonly packageFamily: LinuxPackageFamily | undefined;
readonly hopperSetupSupported: false;
readonly supported: 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;
@@ -157,19 +141,16 @@ export const parseLinuxDistribution = (text: string): LinuxDistribution => {
const key = match[1];
const encoded = match[2];
if (key === undefined || encoded === undefined) continue;
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");
const value =
encoded.startsWith('"') && encoded.endsWith('"')
? encoded.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\")
: encoded;
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 hopperSetupSupported =
const supported =
packageFamily !== undefined &&
(id === "arch" ||
id === "cachyos" ||
@@ -181,9 +162,9 @@ export const parseLinuxDistribution = (text: string): LinuxDistribution => {
id,
...(versionId === undefined ? {} : { versionId }),
};
return hopperSetupSupported
? { ...identity, packageFamily, hopperSetupSupported: true }
: { ...identity, packageFamily, hopperSetupSupported: false };
return supported
? { ...identity, packageFamily, supported: true }
: { ...identity, packageFamily, supported: false };
};
/** Read and classify the current Linux distribution. */
@@ -212,7 +193,7 @@ export const installLinuxHopper = async (
if (options.signal?.aborted === true)
return { status: "failed", reason: "cancelled" };
const distribution = await host.distribution();
if (distribution?.hopperSetupSupported !== true)
if (distribution?.supported !== true)
return { status: "failed", reason: "unsupported_host" };
let temporary: string | undefined;
try {
+4 -16
View File
@@ -43,7 +43,7 @@ export const executeProviderOperation = async <
provider: ProviderOperationPort<TRequest>,
confidence: "observed" | "derived",
): Promise<Result<Evidence, AnalysisError>> => {
if (options?.signal?.aborted)
if (options?.signal?.aborted === true)
return err(new AnalysisCancelledError(operation));
const request = schema.safeParse({ operation, input });
if (!request.success)
@@ -51,10 +51,8 @@ export const executeProviderOperation = async <
const executed = await provider.execute(request.data, options);
if (!executed.ok) return executed;
const execution = executed.value;
const evidence = createEvidence(
execution.subject ?? undefined,
execution.provider,
{
return ok(
createEvidence(execution.subject ?? undefined, execution.provider, {
operation,
parameters: jsonObjectSchema.parse(request.data.input),
result: execution.result,
@@ -62,16 +60,6 @@ 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);
};
+2 -2
View File
@@ -57,7 +57,7 @@ describe("setup workflow", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
hopperSetupSupported: true,
supported: true,
};
host.hopper = "/opt/hopper/bin/Hopper";
host.linuxDemoRuntimeMissing = true;
@@ -79,7 +79,7 @@ describe("setup workflow", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
hopperSetupSupported: true,
supported: true,
};
host.hopper = "/custom/Hopper";
host.unsupportedHopperVersion = true;
+123 -3
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
import { runSetup } from "./Setup.js";
import type { SetupProgressEvent } from "./SetupTypes.js";
import { FakeSetupHost, options } from "./Setup.fixture.js";
@@ -70,7 +71,7 @@ describe("setup workflow", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
hopperSetupSupported: true,
supported: true,
};
const result = await runSetup(
{ ...options(false), structured: false },
@@ -152,6 +153,15 @@ describe("setup workflow", () => {
expect(result.plannedActions.map(({ id }) => id)).toEqual([
"configure_client:codex",
]);
expect(result.plannedActions[0]?.detail).toContain(
`startup_timeout_sec with ${JSON.stringify(MCP_STARTUP_POLICY.codexStartupTimeoutSeconds)}`,
);
expect(result.plannedActions[0]?.detail).toContain(
"registration fields that do not parse are removed",
);
expect(result.plannedActions[0]?.detail).toContain(
"Invalid Codex tools values are removed; valid per-tool approval maps are kept.",
);
expect(host.hopperInstalls).toBe(0);
expect(host.configurations).toBe(0);
});
@@ -162,7 +172,7 @@ describe("setup workflow", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
hopperSetupSupported: true,
supported: true,
};
const result = await runSetup(options(true, true), host);
expect(result.appliedActions).toEqual(["installed_hopper"]);
@@ -185,6 +195,116 @@ describe("setup workflow", () => {
});
});
describe("setup approval disclosure", () => {
it("keeps Grok Build's entry disabled flag in the approval plan", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.skill = "unchanged";
host.clients = [
{
name: "grok_build",
displayName: "Grok Build",
configPath: "/grok.toml",
format: "grok",
},
];
const result = await runSetup(
{ ...options(false), dryRun: true, clientIds: ["grok_build"] },
host,
);
expect(result.plannedActions[0]?.detail).toContain(
`startup_timeout_sec with ${JSON.stringify(MCP_STARTUP_POLICY.codexStartupTimeoutSeconds)}`,
);
expect(result.plannedActions[0]?.detail).toContain(
"A boolean disabled value is kept.",
);
});
it("discloses that Copilot tool selection is replaced", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.skill = "unchanged";
host.clients = [
{
name: "copilot_cli",
displayName: "GitHub Copilot CLI",
configPath: "/copilot.json",
format: "copilot_cli",
},
];
const result = await runSetup(
{ ...options(false), dryRun: true, clientIds: ["copilot_cli"] },
host,
);
expect(result.plannedActions[0]?.detail).toContain(
`tools with ${JSON.stringify(["*"])}`,
);
});
it("discloses that a legacy OpenCode entry keeps only its environment", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.skill = "unchanged";
host.clients = [
{
name: "opencode",
displayName: "OpenCode",
configPath: "/opencode.json",
format: "opencode",
},
];
host.clientInspections.set("opencode", {
status: "update",
dialect: "opencode_v2",
});
const result = await runSetup(
{ ...options(false), dryRun: true, clientIds: ["opencode"] },
host,
);
const detail = result.plannedActions[0]?.detail ?? "";
expect(detail).toContain(
"A legacy OpenCode entry moved into the native server table keeps only its environment.",
);
expect(detail).not.toContain(`enabled with ${JSON.stringify(true)}`);
});
it("describes Cordis fields the Harness writer replaces", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.skill = "unchanged";
host.clients = [
{
name: "dsh",
displayName: "DeepSeek Harness",
configPath: "/cordis.patch.yml",
format: "dsh",
},
];
const result = await runSetup(
{ ...options(false), dryRun: true, clientIds: ["dsh"] },
host,
);
const detail = result.plannedActions[0]?.detail ?? "";
const [executable = "rea", ...args] = host.registrationCommand;
expect(detail).toContain(
'Other fields on the existing Cordis server are kept, except the row-level disabled flag is removed to activate the registration and name is set to "@deepseek-ai/dsh-mcp-client".',
);
expect(detail).toContain(`serverName with ${JSON.stringify("rea")}`);
expect(detail).toContain(`transport with ${JSON.stringify("stdio")}`);
expect(detail).toContain(`command with ${JSON.stringify(executable)}`);
expect(detail).toContain(`args with ${JSON.stringify(args)}`);
expect(detail).not.toContain("Remote-server keys");
});
});
describe("setup workflow lifecycle and provider planning", () => {
it("reports append-only lifecycle progress for each selected action", async () => {
const host = new FakeSetupHost();
@@ -236,7 +356,7 @@ describe("setup workflow lifecycle and provider planning", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
hopperSetupSupported: true,
supported: true,
};
host.ghidra = "/opt/ghidra_12.1.4_PUBLIC";
host.javaHome = "/usr/lib/jvm/jdk-21";
+2 -2
View File
@@ -310,7 +310,7 @@ describe("setup scoped readiness", () => {
id: "debian",
versionId: "13",
packageFamily: "deb",
hopperSetupSupported: false,
supported: 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",
hopperSetupSupported: false,
supported: false,
};
host.ghidra = "/opt/ghidra_12.1.4_PUBLIC";
host.javaHome = "/usr/lib/jvm/jdk-21";
+37 -41
View File
@@ -5,6 +5,7 @@ import {
} from "./ClientConfigurationFiles.js";
import { err, ok, type Result } from "../domain/result.js";
import {
applyRegistrationStartupTimeout,
clientRegistrationEntry,
clientConfigurationValuesEqual,
clientServerListedDisabled,
@@ -13,9 +14,11 @@ import {
OMP_DISABLED_SERVERS_KEY,
parseClientConfiguration,
parseZcodeFallbackConfiguration,
retainClientRegistrationFields,
serializeClientConfiguration,
withClientServers,
type ClientConfigurationDocument,
type ClientRegistrationDialect,
} from "./ClientConfigurationDocument.js";
import { dshDocument, DshCordisDocument } from "./DshCordisPatch.js";
import { constants as fsConstants } from "node:fs";
@@ -27,7 +30,6 @@ import { clientRegistrationEnvironment } from "./ClientRegistrationEnvironment.j
import { PRODUCT_IDENTITY } from "../identity.js";
import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import { packageReadTargetChanged } from "./MsixPackageStorage.js";
import {
@@ -444,7 +446,7 @@ const inspectClientDocument = async (
throwCleanupFailure(fallback.error);
return { status: "invalid", remediation: fallback.error.detail };
}
return { status: "create" };
return { status: "create", dialect: clientRegistrationDialect(client) };
}
return {
status: "invalid",
@@ -474,12 +476,26 @@ const inspectClientDocument = async (
parsed,
);
if (registrationCurrent(parsed, desired))
return { status: "already_current" };
return { status: "already_current", dialect: parsed.dialect };
const fallback = await readZcodeFallbackServers(client, parsed);
if (!fallback.ok) {
throwCleanupFailure(fallback.error);
return { status: "invalid", remediation: fallback.error.detail };
}
const backupSelection = await selectCurrentRecoveryBackup(
`${client.configPath}.rea.backup`,
original,
);
if (!backupSelection.ok)
return {
status: "invalid",
remediation: backupSelection.error.detail,
};
return {
status: "update",
backupPath: backupSelection.value.backupPath,
dialect: parsed.dialect,
};
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure) throw cause;
// Malformed configuration is reported with the invalid remediation.
@@ -490,21 +506,15 @@ const inspectClientDocument = async (
"The existing configuration is malformed. Repair it before setup applies any other changes.",
};
}
const backupSelection = await selectCurrentRecoveryBackup(
`${client.configPath}.rea.backup`,
original,
);
if (!backupSelection.ok)
return {
status: "invalid",
remediation: backupSelection.error.detail,
};
return {
status: "update",
backupPath: backupSelection.value.backupPath,
};
};
const clientRegistrationDialect = (
client: SetupClient,
): ClientRegistrationDialect =>
client.format === undefined || client.format === "unsupported"
? "json"
: client.format;
const isMissing = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
@@ -699,31 +709,17 @@ const clientConfigurationDesired = (
providerEnvironment,
);
if (!environment.ok) throw environment.error;
const registration = clientRegistrationEntry(
const registration = applyRegistrationStartupTimeout(
client.name,
clientRegistrationEntry(format, launcher, environment.value),
);
// Keep fields the user added to REA's own entry, such as OMP `timeout`. A
// legacy OpenCode V1 entry migrating into a V2 table keeps only its
// environment, because its other fields belong to the V1 shape. The approval
// plan states that exception through describeClientRegistrationRetention.
return retainClientRegistrationFields(
format,
launcher,
environment.value,
parsed?.servers[PRODUCT_IDENTITY.mcpServerKey],
registration,
);
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"
? {
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
}
: {}),
...(client.name === "kimi"
? {
startupTimeoutMs: MCP_STARTUP_POLICY.kimiStartupTimeoutMs,
toolTimeoutMs: MCP_STARTUP_POLICY.kimiToolTimeoutMs,
}
: {}),
};
};
+1 -2
View File
@@ -124,8 +124,7 @@ export const hostRemediation = async (
? "Upgrade to macOS 12 or newer."
: undefined;
}
if ((await host.linuxDistribution())?.hopperSetupSupported === true)
return undefined;
if ((await host.linuxDistribution())?.supported === 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.";
};
+24 -7
View File
@@ -1,5 +1,10 @@
import { join } from "node:path";
import {
describeClientRegistrationRetention,
type ClientRegistrationDialect,
} from "./ClientConfigurationDocument.js";
import { describeDshRegistrationRetention } from "./DshCordisPatch.js";
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import {
linuxHopperInstallDisclosure,
@@ -56,8 +61,7 @@ export const discoverSetupState = async (input: {
const hopperInstallSupported =
(input.host.platform === "darwin" &&
majorVersion((await input.host.macosVersion()) ?? "0") >= 12) ||
(input.host.platform === "linux" &&
linuxDistribution?.hopperSetupSupported === true);
(input.host.platform === "linux" && linuxDistribution?.supported === true);
const installHopper =
input.forceHopperInstall ||
(hopperInstallSupported &&
@@ -231,6 +235,9 @@ const plannedClientWrites = (
inspection.backupPath !== undefined
? { backupPath: inspection.backupPath }
: {}),
...(inspection.dialect === undefined
? {}
: { dialect: inspection.dialect }),
},
]
: [],
@@ -248,6 +255,7 @@ const setupPlan = (input: {
readonly client: SetupClient;
readonly operation: "create" | "update";
readonly backupPath?: string;
readonly dialect?: ClientRegistrationDialect;
}[];
readonly providerEnvironment: SetupProviderEnvironment;
readonly linuxPackageFamily?: LinuxPackageFamily;
@@ -285,13 +293,13 @@ const setupPlan = (input: {
: []),
...input.clients
.filter(({ client }) => client.format !== "unsupported")
.map(({ client, operation, backupPath }): SetupAction => ({
.map(({ client, operation, backupPath, dialect }): SetupAction => ({
id: `configure_client:${client.name}`,
kind: "configure_client",
label: client.displayName ?? client.name,
target: client.configPath,
detail: clientConfigurationDetail(
client,
{ ...client, ...(dialect === undefined ? {} : { dialect }) },
input.command,
input.providerEnvironment,
input.installHopper,
@@ -335,7 +343,7 @@ const majorVersion = (version: string): number =>
Number.parseInt(version.split(".")[0] ?? "0", 10);
const clientConfigurationDetail = (
client: SetupClient,
client: SetupClient & { readonly dialect?: ClientRegistrationDialect },
command: readonly string[],
environment: SetupProviderEnvironment,
installHopper: boolean,
@@ -343,10 +351,19 @@ const clientConfigurationDetail = (
const entries = Object.entries(environment)
.sort(([left], [right]) => compareUnicodeCodePoints(left, right))
.map(([name, value]) => `${name}=${value}`);
const dialect =
client.dialect ??
(client.format === undefined || client.format === "unsupported"
? "json"
: client.format);
const retainedFields =
dialect === "dsh"
? describeDshRegistrationRetention(command)
: describeClientRegistrationRetention(client.name, dialect);
const environmentDetail =
entries.length === 0
? " No additional provider environment variables. Existing server environment overrides are retained."
: ` Detected provider environment: ${entries.join(", ")}. Existing server environment overrides are retained; detected values replace matching keys.`;
? ` No additional provider environment variables. Existing server environment overrides are retained. ${retainedFields}`
: ` Detected provider environment: ${entries.join(", ")}. Existing server environment overrides are retained; detected values replace matching keys. ${retainedFields}`;
const hopperDetail = installHopper
? environment.HOPPER_LAUNCHER_PATH === undefined
? " If this plan installs Hopper, REA will add HOPPER_LAUNCHER_PATH using the launcher path reported by that installation."
+16 -40
View File
@@ -72,15 +72,6 @@ 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>;
@@ -253,36 +244,19 @@ const canonicalSkillFiles = async (
const destination = join(root, relativePath);
return {
destination,
content: await canonicalSkillContent(relativePath),
content: await readFile(
new URL(
`../../skills/${PRODUCT_IDENTITY.skillName}/${relativePath}`,
import.meta.url,
),
"utf8",
),
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,
@@ -292,13 +266,15 @@ export const canonicalSkillNeedsInstall = async (
resources?: ArtifactResourceScope,
): Promise<boolean> => {
try {
return await canonicalSkillNeedsInstallByReading(
home,
clientIds,
environment,
platform,
resources,
);
return (
await canonicalSkillFiles(
home,
clientIds,
environment,
platform,
resources,
)
).some(({ content, original }) => original?.content !== content);
} catch (cause: unknown) {
if (
cause instanceof RegularFileCleanupFailure ||
+8 -1
View File
@@ -1,3 +1,4 @@
import type { ClientRegistrationDialect } from "./ClientConfigurationDocument.js";
import type { DoctorReport, DoctorScope, runDoctor } from "./Doctor.js";
import type { LinuxDistribution } from "./LinuxHopper.js";
import type { SkillDestination, SkillInstallResult } from "./SetupSkill.js";
@@ -45,10 +46,16 @@ export type ClientConfigurationResult =
/** Read-only state used to block unsafe setup plans before any mutation. */
export type ClientConfigurationInspection =
| { readonly status: "already_current" }
| {
readonly status: "already_current";
/** Dialect the registration writer will use when this file is parsed. */
readonly dialect?: ClientRegistrationDialect;
}
| {
readonly status: "create" | "update";
readonly backupPath?: string;
/** Dialect the registration writer will use for this file. */
readonly dialect?: ClientRegistrationDialect;
}
| {
readonly status: "invalid";
-13
View File
@@ -45,7 +45,6 @@ export interface SetupClient {
| "grok"
| "omp"
| "pi"
| "kimi"
| "hermes"
| "zcode"
| "dsh"
@@ -166,10 +165,6 @@ 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");
@@ -581,14 +576,6 @@ 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",
+4 -9
View File
@@ -627,16 +627,11 @@ const removeManagedSkills = async (
platform: NodeJS.Platform,
writeBlocker?: (path: string) => string | undefined,
): Promise<UninstallItem> => {
// Every destination shares one summary item; preserve both client and path.
// Every destination shares one summary item, so each result is labeled by
// the directory it refers to.
const results = await Promise.all(
skillDestinations(home, undefined, environment, platform).map(
({ client, path }) =>
removeManagedSkill(
path,
`${client} (${path})`,
fileSystem,
writeBlocker,
),
skillDestinations(home, undefined, environment, platform).map(({ path }) =>
removeManagedSkill(path, path, fileSystem, writeBlocker),
),
);
const failed = results.find(({ status }) => status === "failed");
@@ -83,37 +83,3 @@ 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");
});
+1 -3
View File
@@ -283,9 +283,7 @@ export class BinarySession
previous.route.identity.id,
);
if (!closed.ok) {
// A failed open retires the provider cache, not the investigation.
// Preserve Evidence and Unknown history for export and recovery.
this.clearSnapshot();
this.clearSessionRecords();
this.#clearRuntimeAvailability();
return closed;
}
@@ -30,29 +30,6 @@ it("labels an envelope containing debugger-derived maps as derived", async () =>
expect(evidence.raw_result).toBeNull();
});
it.each([
["x86_64-little-endian", "x86_64"],
["aarch64-little-endian", "arm64"],
] as const)(
"names the CPU family carried by a %s core on created Evidence",
async (architecture, expected) => {
const fixture = recordedCrashFixture("/selected.core", architecture);
const service = new RecordedCrashService({
inspectAndClose(input, options) {
return this.inspect(input, options);
},
identity: RECORDED_CRASH_TEST_PROVIDER,
inspect: () => Promise.resolve(ok(fixture)),
});
const result = await service.inspect({
path: fixture.artifact.path,
include_debugger_context: false,
});
if (!result.ok) throw result.error;
expect(parseEvidence(result.value).subject?.architecture).toBe(expected);
},
);
it.each([
{ path: "relative.elf" },
{ path: "/selected.core", approval: true },
@@ -10,7 +10,6 @@ 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";
@@ -102,9 +101,7 @@ export class RecordedCrashService {
{
path: value.artifact.path,
format: "elf",
architecture:
RECORDED_LINUX_CORE_PROFILES[value.architecture]
.evidenceArchitecture,
architecture: "x86_64",
sha256: value.artifact.sha256,
},
this.provider.identity,
@@ -100,10 +100,4 @@ 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 }),
});
@@ -62,7 +62,26 @@ export const analyzeJavaScriptApplicationValidated = async (
});
await checkpointJavaScriptAnalysis(options.signal);
reconstructed = await reconstructJavaScriptArtifact(
input,
{
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 }),
},
scope,
options.signal,
options.progress,
@@ -457,8 +457,9 @@ const bareModuleCandidate = (
dependencyDirectory = posix.dirname(confined);
}
const source = input.files.get(input.sourcePath);
let directory = posix.dirname(input.sourcePath);
let nearestNotFound: CandidateResolution | null = null;
for (const directory of packageLookupDirectories(input)) {
while (true) {
const candidate = posix.join(directory, dependencyDirectory);
const metadata = readPackageMetadata(input, candidate);
const continueCommonJsLookup =
@@ -486,6 +487,8 @@ const bareModuleCandidate = (
nearestNotFound ??= packageResolution;
} else return packageResolution;
}
if (directory === "." || directory === "") break;
directory = posix.dirname(directory);
}
if (nearestNotFound !== null) return nearestNotFound;
return unresolvedCandidate("external", [
@@ -493,21 +496,6 @@ 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.
@@ -972,29 +960,15 @@ const resolveCandidate = (
candidate: string,
packageChain: ReadonlySet<string>,
emittedSourceCandidates: readonly TypeScriptEmittedSourceCandidate[] = [],
): CandidateResolution => {
if (!commonJsDirectoryRequest(input)) {
const file =
resolveFileCandidates(
input,
fileCandidates(candidate, input.moduleKind),
) ??
resolveRuntimeSourceCandidates(input, candidate, emittedSourceCandidates);
if (file !== null) return file;
}
return resolveDirectory(
): CandidateResolution =>
resolveFileCandidates(input, fileCandidates(candidate, input.moduleKind)) ??
resolveRuntimeSourceCandidates(input, candidate, emittedSourceCandidates) ??
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,
@@ -277,9 +277,15 @@ 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(
input,
parameters,
partial,
);
throw interruption.retainPartialObservation(evidence);
@@ -199,49 +199,6 @@ 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({
@@ -7,59 +7,6 @@ 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());
+36 -57
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,29 +40,15 @@ 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 {
ok: false as const,
error: new AnalysisOutputError(
return err(
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 ||
@@ -78,52 +64,45 @@ export const readNativeCallRoutes = async (
targets.add(call.target_address);
countBySite.set(call.source_address, targets);
}
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,
},
};
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,
});
}
} else if (execution.error._tag !== "AnalysisCapabilityUnavailableError")
return { ok: false as const, error: execution.error, unknowns };
return err(execution.error);
const callees = await analysis.execute(
"procedure_callees",
{ procedure },
options,
);
if (!callees.ok)
return { ok: false as const, error: callees.error, unknowns };
if (!callees.ok) return callees;
const parsed = parseRelatedAddresses(callees.value.result, "callees");
return parsed.ok
? {
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 };
? ok({
provider: callees.value.provider,
routes: parsed.value.map((address) => ({
address,
site: procedure,
relation: "provider_call" as const,
resolution: "inferred" as const,
})),
unknowns: [],
})
: parsed;
};
+28 -38
View File
@@ -414,44 +414,9 @@ 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 (edges.size >= input.input.max_edges) {
truncated = true;
omitted += 1;
continue;
}
if (execution.error._tag === "AnalysisCancelledError")
return { error: execution.error };
const id = `unknown-callees:${current.address}`;
edges.set(id, {
id,
@@ -467,6 +432,32 @@ 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) {
@@ -478,7 +469,6 @@ 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,
@@ -1,198 +0,0 @@
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" }],
},
);
},
);
@@ -1,250 +0,0 @@
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),
},
]),
);
}
};
+25
View File
@@ -0,0 +1,25 @@
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" },
]);
});
});
+53 -21
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 { getRawHeader, uncache } from "@electron/asar";
import { listPackage, statFile, uncache } from "@electron/asar";
import {
ArtifactReaderFailure,
@@ -26,11 +26,7 @@ import { closeAsarHandle, readValidatedAsarEntry } from "./AsarEntryStream.js";
import { admitAsarHeader } from "./AsarHeader.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
import {
readAsarListing,
type AsarListedEntry,
type AsarMetadata,
} from "./AsarListing.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
/**
* Official Electron ASAR adapter with range-streamed member reads.
@@ -55,7 +51,7 @@ export class AsarArtifactReader implements ArtifactReader {
) {}
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
let paths: readonly AsarListedEntry[];
let paths: readonly AsarListedPath[];
try {
await this.prepareContainer(undefined, signal);
paths = await this.#withSnapshotOperation(async () => {
@@ -66,9 +62,8 @@ export class AsarArtifactReader implements ArtifactReader {
this.#archiveSize = admitted.archiveBytes;
this.#headerSize = admitted.headerBytes;
abortIfNeeded(signal);
const ordered = await readAsarListing(
getRawHeader(snapshotPath).header,
signal,
const ordered = orderAsarListing(
listPackage(snapshotPath, { isPack: false }),
);
const archiveMetadata = await lstat(snapshotPath);
if (!archiveMetadata.isFile() || archiveMetadata.isSymbolicLink())
@@ -82,25 +77,34 @@ export class AsarArtifactReader implements ArtifactReader {
} catch (cause: unknown) {
throw asarFailure(this.path, "inventory", cause);
}
for (const { metadata, path } of paths) {
for (const { listed, path } of paths) {
abortIfNeeded(signal);
const providerPath = path.split("/").join(sep);
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 kind =
metadata.files !== undefined
"files" in metadata
? "directory"
: metadata.link !== undefined
: "link" in metadata
? "symlink"
: "file";
const entry: ArtifactEntry = {
path,
kind,
declaredSize: metadata.size ?? null,
declaredSize: "size" in metadata ? metadata.size : null,
compressedSize: null,
executable: metadata.executable === true,
executable: "executable" in metadata && metadata.executable,
encrypted: false,
byteOffset: null,
declaredSha256:
metadata.integrity !== undefined &&
"integrity" in metadata &&
metadata.integrity.algorithm === "SHA256" &&
/^[a-f0-9]{64}$/u.test(metadata.integrity.hash)
? metadata.integrity.hash
@@ -599,7 +603,7 @@ export class AsarArtifactReader implements ArtifactReader {
}
}
type AsarFileMetadata = AsarMetadata & { readonly size: number };
type AsarFileMetadata = Extract<ReturnType<typeof statFile>, { size: number }>;
type AsarEntryState = {
readonly metadata: AsarFileMetadata;
readonly entry: ArtifactEntry;
@@ -648,11 +652,12 @@ const asarUnpackedEntryFailure = (
};
const isAsarFileMetadata = (
metadata: AsarMetadata,
metadata: ReturnType<typeof statFile>,
): metadata is AsarFileMetadata =>
"size" in metadata &&
typeof metadata.size === "number" &&
metadata.files === undefined &&
metadata.link === undefined;
!("files" in metadata) &&
!("link" in metadata);
const parseArchiveOffset = (value: string): number | undefined => {
if (!/^(?:0|[1-9][0-9]*)$/u.test(value)) return undefined;
@@ -694,6 +699,33 @@ 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,
-110
View File
@@ -1,110 +0,0 @@
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),
);
};
-84
View File
@@ -1,84 +0,0 @@
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);
},
);
+4 -6
View File
@@ -60,8 +60,8 @@ export const captureStorage = async (
limitations,
)
: undefined;
const cacheField = recordValue(cacheRaw)?.caches;
const caches = capturedCaches(recordsValue(cacheField));
const cacheValues = recordsValue(recordValue(cacheRaw)?.caches);
const caches = capturedCaches(cacheValues);
const fingerprints = context.input.include_storage_fingerprints
? await captureStorageFingerprints({
context,
@@ -78,10 +78,8 @@ export const captureStorage = async (
indexedRaw !== undefined &&
Array.isArray(indexedValues) &&
indexed.length === indexedValues.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;
cacheRaw !== undefined &&
caches.length === cacheValues.length;
return {
value: {
origin,
+17 -3
View File
@@ -8,8 +8,6 @@ import {
FIELD_RECORD_BUDGET,
} from "./parseFieldDiscovery.js";
import {
BOUNDED_PRODUCER_LIMITS as LIMITS,
prlimitArgumentVector,
readLinuxResourceLimits,
type LinuxResourceLimits,
} from "../process/LinuxResourceLimits.js";
@@ -62,6 +60,15 @@ 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. */
@@ -158,7 +165,14 @@ export class TsharkCaptureProvider implements PacketCapturePort {
options?.signal,
);
effectiveLimits = await this.#resources.readLimits(limits);
const limitArguments = prlimitArgumentVector(effectiveLimits, command);
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,
];
phase = "workspace";
root = await this.createRuntime();
this.#resources.retain(root, undefined);
+14 -14
View File
@@ -6,10 +6,7 @@ import { createApktoolResourceAnalysisProvider } from "../composition/apktool.js
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
apktoolInputSchemas,
type ApktoolOperation,
} from "../domain/apktool/apktoolResourceAnalysis.js";
import type { ApktoolOperation } from "../domain/apktool/apktoolResourceAnalysis.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
@@ -52,19 +49,22 @@ 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: apktoolInputSchemas.decode_android_resources.shape.locale,
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",
),
strings: z
.boolean()
.default(
apktoolInputSchemas.decode_android_resources.shape.include_strings.parse(
undefined,
),
)
.default(true)
.describe(
"Include string resources; use --no-strings to skip projecting them",
),
+1 -1
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().min(1).describe("Application or package path"),
path: z.string().describe("Application or package path"),
}),
options: z.object({
integrityPolicy: z
+1 -4
View File
@@ -48,10 +48,7 @@ const registerOverviewCommands = (
cli.command(CLI_COMMANDS.analyze, {
description: "Get an overview of an app",
args: z.object({
path: z
.string()
.min(1)
.describe("App, program, or analysis database path"),
path: z.string().describe("App, program, or analysis database path"),
}),
options: overviewOptions.extend({
integrityPolicy: javascriptApplicationOptions.shape.integrityPolicy,
-40
View File
@@ -17,46 +17,6 @@ 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",
-26
View File
@@ -1,6 +1,3 @@
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";
@@ -16,29 +13,6 @@ 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",
-2
View File
@@ -4,7 +4,6 @@ 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",
@@ -40,7 +39,6 @@ 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",
-19
View File
@@ -1,27 +1,8 @@
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:
-74
View File
@@ -30,7 +30,6 @@ 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("/")) {
@@ -230,76 +229,3 @@ export const presentInputJsonSchema = (
const described = describeSchema(flattened, flattened, describeProperty);
return isObject(described) ? described : flattened;
};
/**
* Clients that render advertised inputs into SDK declarations expand
* schema-local references with a shared per-input budget: Pi 1.1.0 code-mode
* allows 32 expansions and degrades every later reference to `unknown`
* (morluto/rea#1632), hiding the fields those references describe. The budget
* counts conservatively — every resolvable, non-cyclic reference occurrence
* in every schema position — so any reference-resolving client stays within
* its measured limit.
*/
export const advertisedReferenceExpansionBudget = 32;
const definitionKeys = new Set(["$defs", "definitions"]);
const countReferenceExpansions = (
node: unknown,
root: Readonly<Record<string, unknown>>,
active: ReadonlySet<string>,
count: { value: number },
): void => {
if (Array.isArray(node)) {
node.forEach((child) =>
countReferenceExpansions(child, root, active, count),
);
return;
}
if (!isObject(node)) return;
if (typeof node.$ref === "string" && !active.has(node.$ref)) {
const target = resolveReference(root, node.$ref);
if (target !== undefined) {
count.value += 1;
countReferenceExpansions(
target,
root,
new Set([...active, node.$ref]),
count,
);
return;
}
}
for (const [key, value] of Object.entries(node)) {
if (definitionKeys.has(key)) continue;
if (schemaMaps.has(key) && isObject(value)) {
for (const child of Object.values(value))
countReferenceExpansions(child, root, active, count);
} else if (schemaArrays.has(key) || schemaChildren.has(key)) {
countReferenceExpansions(value, root, active, count);
}
}
};
/** Conservative client expansion count over every schema position. */
export const countLocalReferenceExpansions = (
schema: Record<string, unknown>,
): number => {
const count = { value: 0 };
countReferenceExpansions(schema, schema, new Set(), count);
return count.value;
};
/**
* Keep the shared-definition form of an advertised input while clients can
* still resolve it. Once rendering would exhaust the reference budget, fall
* back to the inline projection, which carries no shared definitions for the
* nonrecursive schemas this repository advertises, so every field renders
* with complete structure in every reference-resolving client.
*/
export const inputSchemaWithinReferenceExpansionBudget = (
shared: Record<string, unknown>,
inline: () => Record<string, unknown>,
budget: number,
): Record<string, unknown> =>
countLocalReferenceExpansions(shared) <= budget ? shared : inline();
@@ -67,7 +67,7 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_recorded_crash"),
kind: "native-provider",
description:
"Inspect a supplied Linux ELF64 amd64 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.",
"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.",
inputSchema: inspectRecordedCrashInputSchema,
outputSchema: evidenceResultOf(recordedCrashSchema),
examples: [
-1
View File
@@ -26,7 +26,6 @@ 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" },
-2
View File
@@ -27,8 +27,6 @@ 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:
-2
View File
@@ -438,7 +438,6 @@ 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,
@@ -623,7 +622,6 @@ 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,
+1 -105
View File
@@ -3,12 +3,7 @@ import { Ajv2020 } from "ajv/dist/2020.js";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import {
advertisedReferenceExpansionBudget,
countLocalReferenceExpansions,
inputSchemaWithinReferenceExpansionBudget,
presentInputJsonSchema,
} from "./inputSchemaPresentation.js";
import { presentInputJsonSchema } from "./inputSchemaPresentation.js";
import { toolContract } from "./toolContracts.js";
import { toolInputSchemaWithMetadata } from "./toolSchemaMetadata.js";
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "./javascript/javascriptRuntimeReconciliationExample.js";
@@ -171,105 +166,6 @@ 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([
-2
View File
@@ -1,5 +1,4 @@
import { z } from "zod";
import { byteSearchResultSchema } from "../domain/native/bytePattern.js";
import {
artifactInventorySummaryViewResultSchema,
machoSummaryViewResultSchema,
@@ -109,7 +108,6 @@ 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),
+2 -24
View File
@@ -2,11 +2,7 @@ import { z } from "zod";
import type { ToolContract } from "./toolContractTypes.js";
import { advertisedInputExamples } from "./advertisedInputExamples.js";
import {
advertisedReferenceExpansionBudget,
inputSchemaWithinReferenceExpansionBudget,
presentInputJsonSchema,
} from "./inputSchemaPresentation.js";
import { presentInputJsonSchema } from "./inputSchemaPresentation.js";
import { digestCanonicalValue } from "../domain/canonicalDigest.js";
const PROPERTY_DESCRIPTIONS: Readonly<Record<string, string>> = {
@@ -70,25 +66,7 @@ export const toolInputSchemaWithMetadata = <Contract extends ToolContract>(
});
const examples = advertisedInputExamples(contract);
return {
// 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,
),
...presentInputJsonSchema(shared, fallbackPropertyDescription),
...(examples.length === 0 ? {} : { examples }),
};
},
@@ -1,63 +0,0 @@
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;
+1 -5
View File
@@ -1,4 +1,3 @@
import { COMPARE_WASM_INTERFACES_CONTRACT } from "./wasmComparisonContracts.js";
import {
inspectWasmArtifactInputSchema,
wasmArtifactSchema,
@@ -41,7 +40,4 @@ 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,
COMPARE_WASM_INTERFACES_CONTRACT,
] as const;
export const WASM_TOOL_CONTRACTS = [INSPECT_WASM_ARTIFACT_CONTRACT] as const;
-28
View File
@@ -417,34 +417,6 @@ 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(
+3 -4
View File
@@ -218,9 +218,8 @@ export const appleProjectionId = (projection: unknown): string =>
`aap_${digest(projection)}`;
/**
* 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
* IPA projection keeps every 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 = (
@@ -228,8 +227,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)),
);
+14 -5
View File
@@ -1,5 +1,4 @@
import { z } from "zod";
import { addressCoordinate } from "./native/addressCoordinate.js";
import type { JsonValue } from "./jsonValue.js";
@@ -39,6 +38,16 @@ 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() }),
@@ -119,9 +128,9 @@ export const functionBodySchema = z.discriminatedUnion("available", [
})
.superRefine((body, context) => {
let total = 0n;
const bounds = new Map<string | null, { start: bigint; end: bigint }>();
const closedSpaces = new Set<string | null>();
let previousSpace: string | null | undefined;
const bounds = new Map<string, { start: bigint; end: bigint }>();
const closedSpaces = new Set<string>();
let previousSpace: string | null = null;
for (const range of body.ranges) {
const start = addressCoordinate(range.start);
const end = addressCoordinate(range.end);
@@ -137,7 +146,7 @@ export const functionBodySchema = z.discriminatedUnion("available", [
});
return;
}
if (previousSpace !== undefined && previousSpace !== start.space)
if (previousSpace !== null && previousSpace !== start.space)
closedSpaces.add(previousSpace);
const previous = bounds.get(start.space);
if (
@@ -339,10 +339,7 @@ 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.some((element) => t.isSpreadElement(element))
? args.elements.length
: null;
return t.isArrayExpression(args) ? args.elements.length : null;
};
const objectHasProperty = (
@@ -429,12 +429,7 @@ export const addSourceMapDirectives = (
end === null
)
continue;
// 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,
);
const commentSource = source.slice(start, end);
if (
!(commentSource.startsWith("//") || commentSource.startsWith("/*")) ||
!(commentSource[2] === "#" || commentSource[2] === "@")
@@ -454,7 +449,9 @@ 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(/\s/u);
const terminator = commentSource
.slice(declaredStart)
.search(commentSource.startsWith("//") ? /\s/u : /[\s*]/u);
const declaredEnd =
terminator === -1 ? commentSource.length : declaredStart + terminator;
if (declaredEnd === declaredStart) continue;
@@ -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,29 +84,6 @@ 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([]);
});
-11
View File
@@ -1,11 +0,0 @@
/** 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) };
};
-33
View File
@@ -1,33 +0,0 @@
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" },
]);
});
});
-125
View File
@@ -1,125 +0,0 @@
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",
});
});
-2
View File
@@ -1,5 +1,4 @@
import { z } from "zod";
import { providerSchema } from "../evidence.js";
import {
compareUnicodeCodePoints,
@@ -116,7 +115,6 @@ 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
+1 -94
View File
@@ -1,8 +1,7 @@
import { describe, expect, it } from "vitest";
import { expect, it } from "vitest";
import {
inspectRecordedCrashInputSchema,
recordedCrashSchema,
type RecordedCrash,
} from "./recordedCrash.js";
import {
recordedCrashFixture,
@@ -283,95 +282,3 @@ 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);
});
});

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