chore: merge current main into PR #2791
This commit is contained in:
commit
1f2e8499dc
|
|
@ -175,7 +175,9 @@ additional evidence only when the provider accepts and matches them.
|
|||
REA has a built-in read-only snapshot parser for x64 PE targets with .NET 8 RTR
|
||||
9.1 metadata. An optional headless Ghidra adapter adds database annotations and
|
||||
broader metadata recovery on Linux; it requires an explicitly configured
|
||||
NativeAOT JAR. These are separate routes. See the
|
||||
NativeAOT JAR. These are separate routes. The built-in parser also runs on
|
||||
Windows Ghidra P0; read frozen literals from its report, because
|
||||
`search_strings` does not include them. See the
|
||||
[NativeAOT guide](https://github.com/morluto/rea/blob/main/docs/providers/ghidra-nativeaot.md)
|
||||
for setup, supported layouts, and host restrictions. Preserve binary identity
|
||||
and distinguish derived metadata from Ghidra database observations. Recovered
|
||||
|
|
@ -247,3 +249,9 @@ resolution is explicit; missing references and size mismatch remain visible.
|
|||
Other payloads are opaque, and this operation does not decode images or simulate
|
||||
Windows resource loading. Consult the budgets and supported tree profile in
|
||||
[offline binary diagnostics](https://github.com/morluto/rea/blob/main/docs/guides/native/binary-diagnostics.md).
|
||||
|
||||
For exact core-module import/export differences, use `compare_wasm_interfaces`
|
||||
with two complete inspection Evidence records (or current-session retained
|
||||
references) and explicit scope. Preserve unknown types/duplicate names; static
|
||||
interface equality is not runtime compatibility. CLI accepts complete inline
|
||||
Evidence in `rea compare-wasm-interfaces input.json --json`.
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import java.util.ArrayDeque;
|
|||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Iterator;
|
||||
|
|
@ -141,6 +142,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
"set_addresses_names",
|
||||
"inspect_native_load_image",
|
||||
"read_bytes",
|
||||
"search_bytes",
|
||||
"address_to_file_offset",
|
||||
"ping",
|
||||
"shutdown",
|
||||
|
|
@ -378,6 +380,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
return switch (request.method) {
|
||||
case "inspect_native_load_image" -> inspectNativeLoadImage(request.params);
|
||||
case "read_bytes" -> readMemoryBytes(request.params);
|
||||
case "search_bytes" -> searchMemoryBytes(request.params, descriptor);
|
||||
case "address_to_file_offset" -> addressToFileOffset(request.params);
|
||||
case "address_name" -> addressName(request.params);
|
||||
case "list_documents" -> listDocuments(request.params);
|
||||
|
|
@ -684,6 +687,160 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
return result;
|
||||
}
|
||||
|
||||
private JsonObject searchMemoryBytes(JsonObject params, SessionDescriptor descriptor) throws Exception {
|
||||
// mask, start, end, and cursor are omitted when absent; JSON encoding drops those keys.
|
||||
Set<String> keys = params.keySet();
|
||||
if (!keys.containsAll(Set.of("document", "pattern", "limit")) ||
|
||||
!Set.of("document", "pattern", "mask", "start", "end", "limit", "cursor").containsAll(keys))
|
||||
throw new RequestFailure("invalid_request", "Bridge request fields are invalid");
|
||||
requireDocument(params);
|
||||
// Public input validation owns the grammar; validate again at the socket boundary.
|
||||
String pattern = requireString(params, "pattern").replaceAll("[\\s\\p{Z}\\uFEFF]", "");
|
||||
String mask = params.has("mask") ? requireString(params, "mask") : null;
|
||||
if (mask != null) mask = mask.replaceAll("[\\s\\p{Z}\\uFEFF]", "");
|
||||
if (pattern.isEmpty() || pattern.length() % 2 != 0 ||
|
||||
(mask != null && mask.length() != pattern.length()))
|
||||
throw new RequestFailure("invalid_request", "Mask length must equal a nonempty byte pattern length");
|
||||
byte[] bytes = new byte[pattern.length() / 2];
|
||||
byte[] masks = new byte[bytes.length];
|
||||
boolean constrained = false;
|
||||
for (int index = 0; index < bytes.length; index++) {
|
||||
monitor.checkCancelled();
|
||||
String pair = pattern.substring(index * 2, index * 2 + 2);
|
||||
if ((!pair.equals("??") && !isHexadecimal(pair, 0, 2)) ||
|
||||
(mask != null && !isHexadecimal(mask, index * 2, index * 2 + 2)))
|
||||
throw new RequestFailure("invalid_request", "Use hexadecimal byte pairs or ?? wildcards");
|
||||
int selected = mask == null ? 255 : Integer.parseInt(mask.substring(index * 2, index * 2 + 2), 16);
|
||||
bytes[index] = (byte) (pair.equals("??") ? 0 : Integer.parseInt(pair, 16));
|
||||
masks[index] = (byte) (pair.equals("??") ? 0 : selected);
|
||||
constrained |= masks[index] != 0;
|
||||
}
|
||||
if (!constrained) throw new RequestFailure("invalid_request", "Pattern must constrain at least one bit");
|
||||
int limit = requireBoundedInteger(params, "limit", 1, 1000);
|
||||
Address start = params.has("start") ? requireAddress(params, "start") : null;
|
||||
Address end = params.has("end") ? requireAddress(params, "end") : null;
|
||||
if (start != null && end != null &&
|
||||
(!start.getAddressSpace().equals(end.getAddressSpace()) || start.compareTo(end) > 0))
|
||||
throw new RequestFailure("invalid_request", "Search bounds must be ordered in one address space");
|
||||
JsonArray identity = new JsonArray();
|
||||
for (String value : new String[] { descriptor.targetSha256, descriptor.profileDigest,
|
||||
bytesHex(bytes), bytesHex(masks), start == null ? "" : canonicalAddress(start),
|
||||
end == null ? "" : canonicalAddress(end), optionalString(params, "document") }) identity.add(value);
|
||||
String binding = bytesHex(MessageDigest.getInstance("SHA-256").digest(identity.toString().getBytes(StandardCharsets.UTF_8)));
|
||||
String cursor = params.has("cursor") ? requireString(params, "cursor") : null;
|
||||
Address resume = null;
|
||||
if (cursor != null) {
|
||||
if (!cursor.startsWith(binding + ":"))
|
||||
throw new RequestFailure("invalid_request", "Byte search cursor does not match the target and query");
|
||||
resume = parseReaAddress(cursor.substring(binding.length() + 1));
|
||||
MemoryBlock resumedBlock = currentProgram.getMemory().getBlock(resume);
|
||||
Address bound = start == null ? end : start;
|
||||
if (resumedBlock == null || !resumedBlock.isInitialized() ||
|
||||
(bound != null && !bound.getAddressSpace().equals(resume.getAddressSpace())) ||
|
||||
(start != null && resume.compareTo(start) < 0) ||
|
||||
(end != null && resume.compareTo(end) > 0))
|
||||
throw new RequestFailure("invalid_request", "Byte search cursor lies outside initialized query memory");
|
||||
}
|
||||
List<MemoryBlock> ordered = new ArrayList<>(List.of(currentProgram.getMemory().getBlocks()));
|
||||
ordered.sort(Comparator.comparing(MemoryBlock::getStart));
|
||||
JsonArray matches = new JsonArray();
|
||||
JsonArray blocks = new JsonArray();
|
||||
JsonArray skipped = new JsonArray();
|
||||
JsonArray notLoaded = new JsonArray();
|
||||
long covered = 0;
|
||||
String next = null;
|
||||
for (MemoryBlock block : ordered) {
|
||||
monitor.checkCancelled();
|
||||
Address low = block.getStart();
|
||||
Address high = block.getEnd();
|
||||
Address bound = start == null ? end : start;
|
||||
if (bound != null && !bound.getAddressSpace().equals(low.getAddressSpace())) continue;
|
||||
// Without bounds, search loaded memory only. Non-loaded file parts
|
||||
// (such as .symtab) and overlays stay reachable through explicit bounds.
|
||||
if (bound == null && (!block.isLoaded() || block.isOverlay())) {
|
||||
notLoaded.add(byteSearchCoverage(block, low, high));
|
||||
continue;
|
||||
}
|
||||
if (start != null && low.compareTo(start) < 0) low = start;
|
||||
if (end != null && high.compareTo(end) > 0) high = end;
|
||||
if (low.compareTo(high) > 0) continue;
|
||||
if (!block.isInitialized()) {
|
||||
skipped.add(byteSearchCoverage(block, low, high));
|
||||
continue;
|
||||
}
|
||||
if (next != null) continue;
|
||||
if (resume != null && low.compareTo(resume) < 0) low = resume;
|
||||
if (low.compareTo(high) > 0 || !low.getAddressSpace().equals(high.getAddressSpace())) continue;
|
||||
Address at = low;
|
||||
Address examinedEnd = high;
|
||||
while (at != null && at.compareTo(high) <= 0 && high.subtract(at) >= bytes.length - 1L) {
|
||||
monitor.checkCancelled();
|
||||
// Ghidra's end address bounds candidate starts, not the final matched byte.
|
||||
Address found = currentProgram.getMemory().findBytes(at, high.subtract(bytes.length - 1L), bytes, masks, true, monitor);
|
||||
monitor.checkCancelled();
|
||||
if (found == null || high.subtract(found) < bytes.length - 1L) break;
|
||||
if (matches.size() == limit) {
|
||||
next = binding + ":" + canonicalAddress(found);
|
||||
examinedEnd = found.addNoWrap(bytes.length - 1L);
|
||||
break;
|
||||
}
|
||||
JsonObject match = new JsonObject();
|
||||
match.addProperty("address", canonicalAddress(found));
|
||||
match.addProperty("segment", block.getName());
|
||||
Long offset = targetFileOffset(block, found).offset();
|
||||
match.add("file_offset", offset == null ? JsonNull.INSTANCE : GSON.toJsonTree(offset));
|
||||
Function function = currentProgram.getFunctionManager().getFunctionContaining(found);
|
||||
JsonObject procedure = new JsonObject();
|
||||
if (function != null) {
|
||||
procedure.addProperty("address", canonicalAddress(function.getEntryPoint()));
|
||||
procedure.addProperty("name", function.getName(true));
|
||||
}
|
||||
match.add("procedure", function == null ? JsonNull.INSTANCE : procedure);
|
||||
matches.add(match);
|
||||
at = found.next();
|
||||
}
|
||||
JsonObject coverage = byteSearchCoverage(block, low, examinedEnd);
|
||||
covered = Math.addExact(covered, coverage.get("bytes").getAsLong());
|
||||
blocks.add(coverage);
|
||||
}
|
||||
JsonObject searched = new JsonObject();
|
||||
searched.add("blocks", blocks);
|
||||
searched.addProperty("bytes", covered);
|
||||
searched.add("skipped_uninitialized", skipped);
|
||||
searched.add("skipped_not_loaded", notLoaded);
|
||||
JsonObject result = new JsonObject();
|
||||
result.add("matches", matches);
|
||||
result.add("searched", searched);
|
||||
result.addProperty("complete", next == null);
|
||||
result.add("next_cursor", next == null ? JsonNull.INSTANCE : GSON.toJsonTree(next));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static JsonObject byteSearchCoverage(MemoryBlock block, Address low, Address high) {
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("segment", block.getName());
|
||||
result.addProperty("start", canonicalAddress(low));
|
||||
result.addProperty("end", canonicalAddress(high));
|
||||
result.addProperty("bytes", high.subtract(low) + 1);
|
||||
return result;
|
||||
}
|
||||
|
||||
private record TargetFileOffset(Long offset, int mappings) {}
|
||||
|
||||
private TargetFileOffset targetFileOffset(MemoryBlock block, Address address) throws Exception {
|
||||
Long offset = null;
|
||||
int mappings = 0;
|
||||
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
|
||||
if (!source.contains(address) || source.getFileBytes().isEmpty() ||
|
||||
!isTargetSource(source.getFileBytes().get())) continue;
|
||||
long candidate = source.getFileBytesOffset(address);
|
||||
if (candidate < 0) continue;
|
||||
offset = candidate;
|
||||
mappings++;
|
||||
}
|
||||
return new TargetFileOffset(mappings == 1 && offset <= 9007199254740991L ? offset : null, mappings);
|
||||
}
|
||||
|
||||
private JsonObject addressToFileOffset(JsonObject params) throws Exception {
|
||||
requireKeys(params, Set.of("document", "address"));
|
||||
requireDocument(params);
|
||||
|
|
@ -691,18 +848,12 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
MemoryBlock block = currentProgram.getMemory().getBlock(address);
|
||||
if (block == null || !block.isInitialized())
|
||||
throw new RequestFailure("invalid_request", "Address has no initialized file-backed memory: " + canonicalAddress(address));
|
||||
Long fileOffset = null;
|
||||
int mappings = 0;
|
||||
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
|
||||
if (!source.contains(address) || source.getFileBytes().isEmpty() ||
|
||||
!isTargetSource(source.getFileBytes().get())) continue;
|
||||
long offset = source.getFileBytesOffset(address);
|
||||
if (offset < 0) continue;
|
||||
fileOffset = offset;
|
||||
mappings++;
|
||||
}
|
||||
if (mappings != 1)
|
||||
throw new RequestFailure("invalid_request", "Address has " + mappings + " authoritative target-file mappings: " + canonicalAddress(address));
|
||||
TargetFileOffset mapping = targetFileOffset(block, address);
|
||||
if (mapping.mappings() != 1)
|
||||
throw new RequestFailure("invalid_request", "Address has " + mapping.mappings() + " authoritative target-file mappings: " + canonicalAddress(address));
|
||||
Long fileOffset = mapping.offset();
|
||||
if (fileOffset == null)
|
||||
throw new RequestFailure("invalid_request", "Address lacks a safe file offset: " + canonicalAddress(address));
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("address", canonicalAddress(address));
|
||||
result.addProperty("file_offset", fileOffset);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ import sys
|
|||
|
||||
PROFILE = "pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2"
|
||||
|
||||
# Linux ET_CORE machine identifiers whose PRSTATUS layout is interpreted with
|
||||
# unchanged pwntools 4.15.0 ctypes. Both share pr_cursig@12, pr_pid@32 and
|
||||
# pr_reg@112; only the register window differs: amd64 is 27 x 8 bytes for a
|
||||
# 336-byte descriptor, AArch64 is 34 x 8 bytes for a 392-byte descriptor.
|
||||
CORE_MACHINES = (62, 183)
|
||||
|
||||
|
||||
class CoreFailure(Exception):
|
||||
def __init__(self, reason, message):
|
||||
|
|
@ -51,12 +57,27 @@ def inspect_core(path, cache):
|
|||
# Other declared OSABIs must never select Linux note ctypes.
|
||||
if content[7] not in (0, 3) or content[8] != 0:
|
||||
raise CoreFailure("unsupported-target", "Initial recorded-core profile requires System V or GNU/Linux OSABI with ABI version zero; selected OSABI=" + str(content[7]) + ", ABI version=" + str(content[8]) + ".")
|
||||
if int.from_bytes(content[16:18], "little") != 4 or int.from_bytes(content[18:20], "little") != 62:
|
||||
raise CoreFailure("unsupported-target", "Initial recorded-core profile requires x86-64 ET_CORE.")
|
||||
machine = int.from_bytes(content[18:20], "little")
|
||||
if int.from_bytes(content[16:18], "little") != 4 or machine not in CORE_MACHINES:
|
||||
raise CoreFailure("unsupported-target", "Initial recorded-core profile requires ET_CORE for x86-64 or AArch64; selected ET_TYPE=" + str(int.from_bytes(content[16:18], "little")) + ", e_machine=" + str(machine) + ".")
|
||||
from elftools.common.exceptions import ELFError
|
||||
from elftools.common.utils import struct_parse
|
||||
from elftools.elf.elffile import ELFFile
|
||||
from pwnlib.elf.datatypes import elf_prstatus_amd64, elf_siginfo_64, user_regs_struct_amd64
|
||||
from pwnlib.elf.datatypes import (
|
||||
elf_prstatus_aarch64,
|
||||
elf_prstatus_amd64,
|
||||
elf_siginfo_64,
|
||||
user_regs_struct_aarch64,
|
||||
user_regs_struct_amd64,
|
||||
)
|
||||
if machine == 62:
|
||||
profile_label = "x86-64"
|
||||
architecture = "x86_64-little-endian"
|
||||
prstatus_type, registers_type = elf_prstatus_amd64, user_regs_struct_amd64
|
||||
else:
|
||||
profile_label = "AArch64"
|
||||
architecture = "aarch64-little-endian"
|
||||
prstatus_type, registers_type = elf_prstatus_aarch64, user_regs_struct_aarch64
|
||||
try:
|
||||
image = ELFFile(io.BytesIO(content))
|
||||
header = image.header
|
||||
|
|
@ -107,17 +128,17 @@ def inspect_core(path, cache):
|
|||
if owner != b"CORE\0":
|
||||
continue
|
||||
if note.n_type == "NT_PRSTATUS":
|
||||
if len(descriptor) < ctypes.sizeof(elf_prstatus_amd64):
|
||||
raise CoreFailure("format", "Linux amd64 NT_PRSTATUS descriptor is truncated.")
|
||||
status = elf_prstatus_amd64.from_buffer_copy(descriptor)
|
||||
if len(descriptor) < ctypes.sizeof(prstatus_type):
|
||||
raise CoreFailure("format", "Linux " + profile_label + " NT_PRSTATUS descriptor is truncated.")
|
||||
status = prstatus_type.from_buffer_copy(descriptor)
|
||||
registers = []
|
||||
for name, kind in user_regs_struct_amd64._fields_:
|
||||
relative = elf_prstatus_amd64.pr_reg.offset + getattr(user_regs_struct_amd64, name).offset
|
||||
for name, kind in registers_type._fields_:
|
||||
relative = prstatus_type.pr_reg.offset + getattr(registers_type, name).offset
|
||||
registers.append({"name": name, "value": hex(getattr(status.pr_reg, name)), "location": location(descriptor_start + relative, ctypes.sizeof(kind), end)})
|
||||
threads.append({"note_index": note_index, "historical_pid": ctypes.c_int32(status.pr_pid).value, "recorded_current_signal": status.pr_cursig, "registers": registers})
|
||||
elif note.n_type == "NT_SIGINFO":
|
||||
if len(descriptor) < ctypes.sizeof(elf_siginfo_64):
|
||||
raise CoreFailure("format", "Linux amd64 NT_SIGINFO descriptor is truncated.")
|
||||
raise CoreFailure("format", "Linux " + profile_label + " NT_SIGINFO descriptor is truncated.")
|
||||
signal = elf_siginfo_64.from_buffer_copy(descriptor)
|
||||
number = ctypes.c_int32(signal.si_signo).value
|
||||
code = ctypes.c_int32(signal.si_code).value
|
||||
|
|
@ -125,7 +146,7 @@ def inspect_core(path, cache):
|
|||
signals.append({"note_index": note_index, "number": number, "code": code, "errno": ctypes.c_int32(signal.si_errno).value,
|
||||
"fault_address": hex(signal.sigfault_addr) if known else None,
|
||||
"fault_address_meaning": "recorded-sigsegv-address" if known else "unknown", "thread_association": "unknown"})
|
||||
return {"format": "elf-core", "architecture": "x86_64-little-endian", "target_execution": "not-performed", "live_process_identity": "unknown",
|
||||
return {"format": "elf-core", "architecture": architecture, "target_execution": "not-performed", "live_process_identity": "unknown",
|
||||
"segments": segments, "notes": notes, "note_padding": padding, "threads": threads, "signals": signals,
|
||||
"note_interpretation_completeness": "unknown",
|
||||
"limitations": ["The initial Linux note profile accepts System V/unspecified or GNU/Linux OSABI with ABI version zero; an unspecified OSABI alone does not prove operating-system origin.",
|
||||
|
|
|
|||
|
|
@ -458,7 +458,7 @@ artifact path. Its `expectedFirstTool` is `null`, and
|
|||
`targetClarificationPassed` grades that limited routing outcome independently
|
||||
of analysis prose heuristics. Analyzing a nearby example cannot pass this case.
|
||||
|
||||
The packaged desktop and parser-comparison scenarios now assess a closed set of
|
||||
The packaged desktop, parser-comparison, and managed scenarios assess a closed set of
|
||||
known fixture claims. Each final answer must be a strict JSON manifest containing
|
||||
exactly the requested claim IDs, values, Evidence IDs, and Evidence authority and
|
||||
confidence. Golden values are not included in the prompt. The evaluator checks
|
||||
|
|
@ -475,15 +475,24 @@ analyses and use the requested module and export selectors. Boundary tests packa
|
|||
the same source-owned desktop fixture and compare the same source-owned parser
|
||||
files through actual MCP tool results, then verify that fabricated answers fail.
|
||||
|
||||
The managed rubric checks the exact public top-level type set and the CLI entry
|
||||
point's declaring type and method name. The exact type-set claim requires an
|
||||
unfiltered member inventory: `selection` must be null and the Evidence parameters
|
||||
must omit `type_names`. A selected inventory can still ground the entry-point
|
||||
claim. Both inspections must describe the freshly built DLL's SHA-256. Entry-point
|
||||
resolution additionally requires complete artifact and member metadata, matching
|
||||
non-null module MVIDs, and a unique method at the header's metadata token. These are
|
||||
static metadata claims, not runtime behavior;
|
||||
compiler-generated member counts, token values, and MVIDs are not golden answers.
|
||||
|
||||
Per-scenario `factualCorrectness` is `passed`, `failed`, or `not_assessed` within
|
||||
the `configured_fixture_claims` scope. A pass establishes only the configured
|
||||
claims, not unrestricted factual correctness. Native, managed, browser,
|
||||
claims, not unrestricted factual correctness. Native, browser,
|
||||
navigation-context, and address-context scenarios have no factual rubric and
|
||||
remain `not_assessed`; the managed workflow requires both artifact and member
|
||||
inspection to answer its type and entry-point question.
|
||||
remain `not_assessed`.
|
||||
|
||||
The closed factual scenarios request complete producer Evidence on the initial
|
||||
analysis so their source selectors can authenticate all configured facts.
|
||||
inspection or analysis so their source selectors can authenticate all configured facts.
|
||||
Summary/page/item workflows are exercised separately by `javascript-module-view`;
|
||||
its heuristic gate does not establish full-producer factual correctness.
|
||||
|
||||
|
|
|
|||
|
|
@ -646,6 +646,12 @@ records independent symbol/directory/SHA oracles; it never executes the target.
|
|||
Linux also builds stripped, ordinary-native and small malformed/ambiguous/layout
|
||||
negative inputs. The optional Windows fixture workflow builds a PE on a Windows
|
||||
runner; analyzing that PE on Linux does not verify a Windows Ghidra host.
|
||||
On a local Windows host, the .NET 8 ILCompiler finds the linker only through
|
||||
Visual Studio 2019 or 2022 (`vswhere -version [16,18)`). With only Visual
|
||||
Studio Build Tools 2026 installed, open its x64 Developer Command Prompt.
|
||||
In that same `cmd.exe` session, run `set IlcUseEnvironmentalTools=true` and
|
||||
then `npm run build:fixtures:nativeaot`. Running `vcvars64.bat` as a child of
|
||||
PowerShell does not preserve its environment in the parent PowerShell session.
|
||||
|
||||
Build the clean pinned upstream adapter with `build:ghidra:nativeaot`, then set
|
||||
`REA_GHIDRA_NATIVEAOT_JAR`. Run `verify:ghidra:nativeaot -- symbols`, `-- stripped`,
|
||||
|
|
|
|||
|
|
@ -129,6 +129,18 @@ and Claude Code reports that autocompact is thrashing. To avoid this:
|
|||
The sizes above come from REA's own `tools/list`. A live Claude Code session
|
||||
against DeepSeek's API is unverified.
|
||||
|
||||
## Kimi Code
|
||||
|
||||
Setup registers `rea` in `~/.kimi-code/mcp.json`, or
|
||||
`$KIMI_CODE_HOME/mcp.json` when set, while preserving unrelated settings and
|
||||
creating a `.rea.backup` before updating an existing file. Kimi Code infers
|
||||
stdio from the `command` entry, so setup does not add a `type` field. The
|
||||
registration includes `startupTimeoutMs = 30000` and `toolTimeoutMs = 120000`.
|
||||
Kimi Code also discovers personal skills in `~/.agents/skills` and project-local
|
||||
`.agents/skills`, so no separate skill root is required; setup still installs
|
||||
and verifies the matching skill files through the normal shared-skill flow.
|
||||
Start a new Kimi Code session to pick up MCP and skill changes.
|
||||
|
||||
## Pi
|
||||
|
||||
For [Pi](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/mcp.md),
|
||||
|
|
|
|||
|
|
@ -279,6 +279,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
|
|||
| GitHub Copilot CLI | `copilot_cli` |
|
||||
| Command Code | `commandcode` |
|
||||
| Qwen Code | `qwen_code` |
|
||||
| Kimi Code | `kimi` |
|
||||
| VS Code | `vscode` |
|
||||
| Grok Build | `grok_build` |
|
||||
| OMP | `omp` |
|
||||
|
|
@ -290,8 +291,8 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
|
|||
| ZCode | `zcode` |
|
||||
| DeepSeek Harness | `dsh` |
|
||||
|
||||
`dsh` is main-only until a release after 6.4.0. The registry check above does
|
||||
not cover it.
|
||||
`dsh` and `kimi` require a release containing them. The registry check above does
|
||||
not cover them.
|
||||
|
||||
See [client configuration and troubleshooting](client-configuration.md) for
|
||||
configuration paths, profiles, trust policies, and schema-limit workarounds.
|
||||
|
|
@ -310,9 +311,12 @@ Hopper is separate commercial software with its own license. Its free demo has
|
|||
vendor-defined limits, and a paid license is optional. REA reuses any detected
|
||||
installation and preserves Hopper during uninstall.
|
||||
|
||||
The supported native host baseline is macOS 12+, Ubuntu 24.04+, Fedora 41+,
|
||||
Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy. Ghidra and IDA have their own provider-specific
|
||||
host requirements; Windows Ghidra uses the [experimental P0 boundary](../providers/windows-ghidra-p0.md).
|
||||
The automated Hopper installer supports macOS 12+, Ubuntu 24.04+, Fedora 41+,
|
||||
Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy. REA itself runs on any
|
||||
Linux distribution with a recognized apt, dnf, or pacman package family, where
|
||||
an existing Hopper launcher is selected through `HOPPER_LAUNCHER_PATH`. Ghidra
|
||||
and IDA have their own provider-specific host requirements; Windows Ghidra uses
|
||||
the [experimental P0 boundary](../providers/windows-ghidra-p0.md).
|
||||
|
||||
On macOS, approved setup downloads the official DMG, checks its published size
|
||||
and digest, validates the application bundle, and atomically installs it to
|
||||
|
|
|
|||
|
|
@ -233,8 +233,9 @@ these routes, their layout/host restrictions, derived-memory provenance, and
|
|||
verification coverage.
|
||||
|
||||
REA's experimental Windows Ghidra boundary admits native x86 and x86-64 PE
|
||||
applications and DLLs for static analysis. This does not establish Windows
|
||||
NativeAOT metadata-recovery coverage.
|
||||
applications and DLLs for static analysis. For x64 PE NativeAOT, the read-only
|
||||
parser also runs there; the [NativeAOT guide](ghidra-nativeaot.md) lists what a
|
||||
Windows host covers.
|
||||
|
||||
For NativeAOT analysis, preserve the selected native artifact's path and digest
|
||||
and report recovered type data with its returned provenance. Linking those types
|
||||
|
|
|
|||
|
|
@ -549,3 +549,17 @@ Value projection preserves serialized node kinds, IDs, references and pagination
|
|||
Exact and ambiguous value counts describe emitted root/object value instances;
|
||||
a root and an object may contain the same value. Archive digests identify the
|
||||
original bytes.
|
||||
|
||||
## Locate a byte signature
|
||||
|
||||
Use `search_bytes` after opening a target with Ghidra to locate a known constant
|
||||
or instruction sequence. For example, `{"pattern":"48 8B ?? ?? 89","limit":100}`
|
||||
returns ordered addresses with segments, source file offsets and containing
|
||||
procedures. The CLI equivalent is
|
||||
`rea search-bytes ./program '48 8B ?? ?? 89' --provider ghidra`.
|
||||
Use `start`/`end` to constrain the inclusive address window, and continue with
|
||||
`next_cursor` until `complete` is true. Inspect `searched` for page coverage and
|
||||
skipped uninitialized blocks. A missing match does not establish absence from
|
||||
uninitialized memory or across block boundaries. See
|
||||
[Ghidra byte search](ghidra-provider.md#masked-byte-search) for mask and coverage
|
||||
semantics. This query never executes or modifies target bytes.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
# Recorded crash evidence
|
||||
|
||||
`inspect_recorded_crash` / `rea inspect-recorded-crash` inspect a caller-selected
|
||||
Linux ELF64 x86-64 little-endian core independently of an active binary target.
|
||||
Linux ELF64 little-endian core independently of an active binary target. Two
|
||||
architecture profiles are interpreted, x86-64 and AArch64; each binds registers
|
||||
to its own `NT_PRSTATUS` layout, and any other `e_machine` is refused as
|
||||
`unsupported_target` before note interpretation.
|
||||
|
||||
```sh
|
||||
export REA_PWNTOOLS_PYTHON=/absolute/existing/venv/bin/python
|
||||
|
|
@ -10,8 +13,8 @@ rea inspect-recorded-crash ./crash.core --json
|
|||
|
||||
The selected Python needs unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2.
|
||||
REA does not install these dependencies. ELFFile/its original ELF structures and
|
||||
pwntools' original Linux amd64 ctypes provide parsing and ABI layout. The adapter
|
||||
adds bounds, representation preservation and lifecycle ownership.
|
||||
pwntools' original Linux amd64 and aarch64 ctypes provide parsing and ABI layout.
|
||||
The adapter adds bounds, representation preservation and lifecycle ownership.
|
||||
The initial Linux note profile accepts System V/unspecified and GNU/Linux OSABI
|
||||
with ABI version zero. Other declared ABIs return `unsupported_target` before
|
||||
note interpretation; an unspecified OSABI alone does not prove OS origin.
|
||||
|
|
@ -27,7 +30,8 @@ note interpretation; an unspecified OSABI alone does not prove OS origin.
|
|||
- Every exact Linux CORE PRSTATUS note as a separate recorded thread, with
|
||||
historical PID, signal and lossless uint64 registers bound to source bytes.
|
||||
Each supported note has exactly one interpretation; each thread contains the
|
||||
complete 27-register ABI set, with names bound to their original ABI offsets.
|
||||
complete ABI register set for its architecture (27 registers for amd64,
|
||||
34 for AArch64), with names bound to their original ABI offsets.
|
||||
Physical note ranges cannot overlap or leave segment gaps. ELF and note headers
|
||||
are independently bound to the snapshot, including extended program counts.
|
||||
- Signed SIGINFO number/code/errno. Initially only SIGSEGV codes 1/2 establish the
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ Check `binary_session` for configured capability availability. A recognized
|
|||
header is not validation of the complete module; missing WABT is reported
|
||||
separately as an unavailable capability, not as missing native engine support.
|
||||
|
||||
The recognized WASM header is checked before automatic database routing from a
|
||||
.hop filename. Renaming a module to .hop therefore keeps the static-inspection
|
||||
diagnostic. An explicit database selection or raw/COM format selection retains
|
||||
the caller's requested interpretation; ordinary .hop database routing is unchanged.
|
||||
|
||||
Set `REA_WABT_BIN_DIRECTORY` to an absolute directory containing executable
|
||||
`wasm-validate`, `wasm-objdump` and `wasm2wat` (with `.exe` on Windows). Missing
|
||||
executables, unsupported banners and inaccessible tools produce actionable
|
||||
|
|
@ -79,3 +84,46 @@ Both CLI and MCP use the same typed port and application service. MCP publishes
|
|||
SDK-compatible schemas and records Evidence for `get_evidence_bundle` readback.
|
||||
See [WABT provenance](https://github.com/morluto/rea/blob/main/third_party/wabt/README.md) and
|
||||
[testing](../../development/testing.md#offline-wasm-artifacts) for real-tool verification.
|
||||
|
||||
## Compare core-module interfaces
|
||||
|
||||
Inspection adds producer-owned typed `interface` facts while keeping exact WAT
|
||||
import/export forms. Function/tag signatures resolve type indices; table/memory
|
||||
limits, memory page size and global value type/mutability remain explicit.
|
||||
Unadmitted types (including GC reference signatures) retain null signatures and
|
||||
issues. Exact decoded names retain escapes, empty names, BOM and punctuation.
|
||||
This facet is absent in historical Evidence that did not examine typed interfaces.
|
||||
|
||||
`compare_wasm_interfaces` accepts two complete `inspect_wasm_artifact` Evidence
|
||||
records, or exact retained references in the current MCP session, and required
|
||||
`scope: "imports" | "exports" | "both"`. It reads no files and needs no active
|
||||
WABT toolchain. CLI uses portable inline Evidence in one JSON document:
|
||||
|
||||
```bash
|
||||
rea compare-wasm-interfaces comparison-input.json --json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"left": { "kind": "retained-evidence", "evidence_id": "ev_<left-digest>" },
|
||||
"right": { "kind": "retained-evidence", "evidence_id": "ev_<right-digest>" },
|
||||
"scope": "both"
|
||||
}
|
||||
```
|
||||
|
||||
The references above are MCP-only; put complete Evidence objects in the CLI
|
||||
input. Resolve summary delivery to its retained complete parent before comparing.
|
||||
Results retain both input Evidence IDs, artifact paths/digests/lengths and every
|
||||
selected occurrence. Unique names are added, removed, changed or unchanged;
|
||||
duplicate names and unsupported signatures are unknown. Incomplete or historical
|
||||
inventories never establish absence. Imports group by module/name, exports by
|
||||
name, so a change of kind is visible. Output ordering is deterministic.
|
||||
|
||||
Static type equality does not establish runtime compatibility, ABI compatibility
|
||||
or linking success. Comparison covers core modules; WebAssembly components and
|
||||
canonical ABI relationships belong to their separate inspection workflow.
|
||||
|
||||
After building, run `REA_WABT_BIN_DIRECTORY=/absolute/wabt/bin node
|
||||
scripts/verify-wasm-interface-comparison.mjs` for original-module comparison
|
||||
through the compiled CLI and stdio MCP. It uses owned fixtures and WABT 1.0.42;
|
||||
no WASM module is instantiated or executed.
|
||||
|
|
|
|||
|
|
@ -17,10 +17,14 @@ submodule and is a separate, more capable workflow.
|
|||
The parser currently handles x64 PE32+ and RTR 9.1, including bounded
|
||||
DEHYDRATED_DATA processing, MethodTable relationships, virtual slots, and
|
||||
frozen strings. Other RTR versions and PE architectures are reported as
|
||||
unsupported or partial. The matching .NET 8.0.22 fixture is source-built and
|
||||
the parser's Windows-host Ghidra workflow has not yet had a real Windows-host
|
||||
run. This does not establish Windows Ghidra import or decompiler behavior.
|
||||
Windows Ghidra P0 has no database mutation authority. A PE/CLI or ReadyToRun
|
||||
unsupported or partial. The matching .NET 8.0.22 fixture is source-built.
|
||||
|
||||
On a Windows host, Windows Ghidra P0 runs the parser without a JAR. Metadata,
|
||||
MethodTable relationships, virtual slots and frozen strings come from the
|
||||
parser report, and `analyze_function` decompiles slot targets. Tools that read
|
||||
the Ghidra database do not see rehydrated bytes (see below), because only the
|
||||
optional adapter writes them into the database and Windows Ghidra P0 has no
|
||||
database mutation authority. A PE/CLI or ReadyToRun
|
||||
assembly should use `inspect_managed_artifact`; it is not NativeAOT.
|
||||
|
||||
## Build and configure
|
||||
|
|
@ -57,7 +61,9 @@ returns `observations.metadata_recovery` inline from the captured source snapsho
|
|||
including the RTR header, MethodTable addresses, frozen strings, derived overlay
|
||||
identity, and examined/recovered counts. Its enclosing `unsupported` load-image
|
||||
status means independent PE load-image verification was not performed; it does
|
||||
not mean the metadata parser failed or the executable failed to load.
|
||||
not mean the metadata parser failed or the executable failed to load. Without
|
||||
the optional adapter, each recovered type's `type` is `null`, because no Ghidra
|
||||
DataType exists; select it by `address`.
|
||||
|
||||
Pass a returned MethodTable address to `inspect_native_data_type`. A direct PE
|
||||
query also derives the snapshot metadata from the authenticated Ghidra session
|
||||
|
|
@ -67,8 +73,12 @@ The database result remains `unavailable` when Ghidra has no DataType at that
|
|||
address; its `metadata_recovery.source` is `read-only-derived-overlay` and
|
||||
carries related type, interface, slot, and target observations. Generated
|
||||
address identities do not claim original managed names. The parser does not
|
||||
merge frozen literals into Ghidra's database string inventory. Follow slot
|
||||
targets with `analyze_function` and ordinary cross-references.
|
||||
merge frozen literals into Ghidra's database string inventory: read them from
|
||||
`metadata_recovery.frozen_strings`, not `search_strings`. Slot targets come
|
||||
from the parser report; on Windows P0 without the adapter, a `read_bytes` of
|
||||
a slot in the rehydrated region returns no bytes. The Linux adapter route
|
||||
verifies a complete slot read and its target. Follow slot targets with `analyze_function` and ordinary
|
||||
cross-references.
|
||||
|
||||
The built-in CLI path uses a MethodTable address returned by the parser or
|
||||
another observation:
|
||||
|
|
@ -129,3 +139,11 @@ compiler/linker symbols and raw directory bytes provide the oracle. Stripped
|
|||
fixtures exercise heuristic detection. Tests do not execute the resulting
|
||||
NativeAOT binaries. Fixture generation and adapter compilation are optional and
|
||||
never prerequisites of ordinary native analysis or setup.
|
||||
|
||||
The real MCP lane runs only on a Linux x64 host with the adapter. The
|
||||
Windows-host behavior above was checked manually over MCP on Windows 11 x64
|
||||
(non-elevated, Ghidra 12.1.4, JDK 21, no JAR) with the source-built win-x64
|
||||
symbols fixture. The format, RTR version and frozen strings matched the fixture
|
||||
oracle; the derived MethodTable, its base type, its interface and a virtual
|
||||
slot reaching the overridden method matched the linker map; and pseudocode was
|
||||
readable. No CI lane repeats this check.
|
||||
|
|
|
|||
|
|
@ -198,3 +198,32 @@ It accepts the same architecture and DLL flags and verifies the selected PE role
|
|||
in the analysis profile.
|
||||
All lanes require matching native controls; package startup alone establishes
|
||||
only package compatibility.
|
||||
|
||||
## Masked byte search
|
||||
|
||||
`search_bytes` (`rea search-bytes PATH '48 8B ?? ?? 89' --provider ghidra`)
|
||||
searches initialized analyzed memory, including loader fixups. Supply hexadecimal
|
||||
byte pairs, with `??` for any byte. An optional equal-length hexadecimal `mask`
|
||||
selects bits: `pattern: "a0"`, `mask: "f0"` matches bytes from `a0` through `af`.
|
||||
Wildcards remain unconstrained even with an explicit mask. At least one bit
|
||||
must be constrained.
|
||||
|
||||
Optional `start` and `end` addresses are inclusive and select one address space.
|
||||
A match must end at or before `end`, so `end` set inside a match excludes it.
|
||||
Without bounds, loaded memory blocks are eligible, in Ghidra address order.
|
||||
Blocks that are not loaded (file parts such as `.symtab`) and overlay blocks are
|
||||
listed in `searched.skipped_not_loaded` and are not searched; pass `start` or
|
||||
`end` in their address space to search them.
|
||||
Overlapping matches are included; matches never cross block boundaries.
|
||||
Each match reports its address, segment, unique source file offset (or null),
|
||||
and containing procedure (or null).
|
||||
|
||||
Pages default to 100 matches and accept 1 to 1000. Pass `next_cursor` as `cursor`
|
||||
with the same target, profile, pattern, mask and bounds to continue. `complete`
|
||||
means no further matches remain. `searched.blocks` and `searched.bytes` describe
|
||||
the inclusive memory ranges examined for this page, including lookahead for the
|
||||
next match. Coverage can overlap between pages; do not sum it as unique memory.
|
||||
`searched.skipped_uninitialized` lists eligible uninitialized blocks on every
|
||||
page. These blocks are never searched. Cancellation returns a typed error,
|
||||
discards the active Ghidra database, and does not return a successful partial page.
|
||||
Hopper byte search remains unavailable until its implementation is verified.
|
||||
|
|
|
|||
|
|
@ -29,8 +29,10 @@ admitted raw NMOS 6502 mapping on Windows. Windows P0 admission remains limited
|
|||
to native PE and raw NMOS 6502 images. For x64 PE
|
||||
NativeAOT RTR 9.1, the read-only snapshot parser can report metadata through
|
||||
`inspect_native_load_image` and `inspect_native_data_type` without a JAR or
|
||||
database mutation. A real Windows-host NativeAOT Ghidra run is still required
|
||||
to establish the complete host workflow.
|
||||
database mutation: MethodTable relationships, virtual slots and frozen strings.
|
||||
`analyze_function` decompiles slot targets. Database reads of the rehydrated
|
||||
bytes (`read_bytes`, `search_strings`) return nothing on this host. See
|
||||
[NativeAOT](ghidra-nativeaot.md).
|
||||
|
||||
This boundary does not establish general Windows feature parity. Ghidra GUI
|
||||
state, annotations, other target architectures and formats, and Hopper-only
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ unresolved relationship reads. Cancellation remains a tool error.
|
|||
REA prepares complete MCP results within the pinned stdio client's 10 MiB
|
||||
receive-buffer budget, including both text and structured representations and
|
||||
room for the JSON-RPC envelope. Tool errors carry only their text
|
||||
representation, so only that text counts against the budget. Response budget settings are captured at startup;
|
||||
representation and `isError` flag, so only those count against the budget. Response budget settings are captured at startup;
|
||||
restart or recreate the server to apply changes. If a result cannot fit, REA returns
|
||||
`resource_constraint` with `details.resource: "transport"` before constructing
|
||||
a document-sized string. Analysis Evidence remains complete in the current
|
||||
|
|
@ -271,9 +271,11 @@ Evidence remain complete inline, and both input forms pass the same semantic,
|
|||
identity, authority, and provenance checks without running the producer again.
|
||||
|
||||
References belong to the current connection's Evidence ledger. Opening another
|
||||
target preserves retained records; `close_binary` clears them, even when no
|
||||
binary is active. A fresh connection has its own ledger. A missing reference
|
||||
reports its exact ID and `details.reason: "missing"`; the server cannot infer
|
||||
target or restarting the current target preserves retained records, including
|
||||
when provider cleanup fails; records remain available for export and retry.
|
||||
`close_binary` clears them, even when no binary is active. A fresh connection
|
||||
has its own ledger. A missing reference reports its exact ID and
|
||||
`details.reason: "missing"`; the server cannot infer
|
||||
whether it was never recorded, cleared, or retained by another connection.
|
||||
Supply complete inline Evidence, repeat its producer, or import an exported
|
||||
Evidence bundle before referencing that imported record. Export a bundle before
|
||||
|
|
@ -602,8 +604,12 @@ failures for such entries name this cause and the operation to rerun with
|
|||
|
||||
Advertised input and output schemas contain no reachable recursive references.
|
||||
Schemas share repeated definitions through schema-local references while
|
||||
preserving complete nested fields and validation rules. Input properties retain
|
||||
their descriptions and literal examples.
|
||||
preserving complete nested fields and validation rules. Input schemas whose
|
||||
shared definitions would exhaust a client's declaration-rendering reference
|
||||
budget (Pi 1.1.0 code-mode shares 32 reference expansions across an input and
|
||||
renders every later reference as `unknown`) are re-projected without shared
|
||||
definitions, so every advertised field renders with complete structure.
|
||||
Input properties retain their descriptions and literal examples.
|
||||
|
||||
When a host forwards input schemas to the model, their examples consume context.
|
||||
REA's advertised examples therefore never copy complete producer Evidence.
|
||||
|
|
@ -650,7 +656,7 @@ JSON Schema:
|
|||
objects or arrays.
|
||||
- The few schemas whose validation structure alone exceeds the budget
|
||||
(`compare_web_captures`, `build_reconstruction_obligation_ledger`,
|
||||
`evaluate_reconstruction_coverage`, `project_managed_application_graph`,
|
||||
`evaluate_reconstruction_coverage`,
|
||||
`compare_application_versions`, `capture_browser_scenario`) are advertised
|
||||
in a reduced form: property names, reference-resolved types (including union
|
||||
alternatives), first-level guidance, and the root's required-field constraints,
|
||||
|
|
@ -660,8 +666,7 @@ JSON Schema:
|
|||
instead of silent acceptance.
|
||||
|
||||
Boundary tests pin every advertised compact schema to the budget, keep them
|
||||
valid JSON Schema that still accepts every canonical example, and hold the
|
||||
set of six reduced presentations as contracts evolve. Client verification
|
||||
valid JSON Schema that still accepts every canonical example. Client verification
|
||||
also checks provider-facing sizes and canonical examples after conversion by
|
||||
the actual Kimi Code CLI; local captures do not establish Moonshot acceptance.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
# Plan
|
||||
|
||||
1. Check issue/timeline and complete open/closed PR searches; review #2406.
|
||||
2. Extend the existing core-module WABT inspection in an isolated worktree.
|
||||
3. Extend producer-owned inspection with optional typed interface facts;
|
||||
preserve unknown unsupported signatures and exact WAT forms.
|
||||
4. Add shared Evidence-only comparator and strict input/output contracts.
|
||||
5. Register additive CLI/MCP workflow and documentation.
|
||||
6. Validate original-module workflows, malformed/unknown/cancellation cases;
|
||||
run checks, independent review, repair findings and rerun affected checks.
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# Core WebAssembly interface comparison (#2356)
|
||||
|
||||
## Outcome and scope
|
||||
|
||||
Compare two exact authenticated `inspect_wasm_artifact` Evidence inputs, inline
|
||||
or retained session references, under required `scope: imports | exports | both`.
|
||||
No module acquisition or execution occurs during comparison. This is a derived
|
||||
workflow within existing WABT inspection authority, not a new provider.
|
||||
|
||||
Producer-owned typed `interface` facts are additive and optional in historical
|
||||
inspection Evidence. WABT's validated canonical WAT owns decoding; exact original
|
||||
forms and names remain available. Comparison never reparses retained display text.
|
||||
Function/tag signatures resolve type uses; memory page size defaults to 65536 bytes and explicit custom page sizes are retained; table/memory limits and global types
|
||||
are compared exactly. Unsupported type/proposal forms remain unknown rather than
|
||||
being compared by raw text or inferred compatible. Names are exact decoded UTF-8,
|
||||
without normalization, including empty names, escapes, punctuation and BOM.
|
||||
|
||||
## Comparison contract
|
||||
|
||||
Return both Evidence IDs and original artifact path/digest/length. Group imports
|
||||
by exact module/name and exports by exact name, independently of kind, so a kind
|
||||
change is reported as changed. Unique admitted signatures yield added, removed,
|
||||
changed or unchanged. Duplicate keys retain every original occurrence and yield
|
||||
unknown; no Cartesian pairing or deduplication. Missing historical facets and
|
||||
incomplete inventories prevent absence claims. Unknown signatures do not erase
|
||||
independently observed entries. Ordering is deterministic by direction and names.
|
||||
|
||||
Both subject digest/path and inspection parameters must match normalized artifact
|
||||
identity. Authenticate Evidence before projection; summary Evidence must be
|
||||
resolved to its retained complete parent by the caller. Report unknown coverage
|
||||
and explicit limitations. Exact static equality is not runtime compatibility,
|
||||
ABI compatibility, linking success or source identity.
|
||||
|
||||
## Authority and overlap
|
||||
|
||||
Reads supplied Evidence only. MCP records derived Evidence through the existing
|
||||
handler/ledger; CLI accepts portable inline Evidence from an explicit JSON input.
|
||||
No new provider, implicit fetch, target mutation or foreign-code execution.
|
||||
PR #2406 (head 86865035) adds component inspection, preamble routing and embedded
|
||||
core-module inspection. Its component parser is not required or duplicated here;
|
||||
shared contract/CLI registration changes remain additive. Core GC/recursive types
|
||||
are outside admitted signature semantics and keep unknown mappings.
|
||||
|
||||
## Resource admission
|
||||
|
||||
Typed producer rows, names and declaration tokenization are admitted before
|
||||
allocation against the existing 32 MiB WABT output representation capacity.
|
||||
Comparison uses linear grouping and cooperative transport yields; it never
|
||||
builds Cartesian duplicate pairings. CLI JSON input retains the shared file
|
||||
admission gate. Token usage and peak process-family memory remain unmeasured.
|
||||
|
||||
## Verification
|
||||
|
||||
Original owned modules assembled using WABT 1.0.42 cover semantically equivalent
|
||||
encodings, changes of kind/signature, names/duplicates, imported index spaces,
|
||||
tables/memory/globals/tags, unknown types and historical incomplete Evidence.
|
||||
Exercise authenticated identity, cancellation, retained lookup, CLI/MCP parity
|
||||
and advertised schemas. Run focused existing WASM/Evidence tests, repository
|
||||
checks and docs validation; independent subagent review before handoff.
|
||||
|
||||
Format reference: https://webassembly.github.io/spec/core/binary/modules.html
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# Tasks
|
||||
|
||||
- [x] Confirm scope and overlap boundaries against #2406.
|
||||
- [x] Record spec and implementation plan.
|
||||
- [x] Implement typed producer interface and shared comparison.
|
||||
- [x] Add CLI/MCP contracts and public workflow tests.
|
||||
- [x] Run focused, static and documentation checks.
|
||||
- [x] Resolve independent review findings and rerun relevant checks.
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# Verification
|
||||
|
||||
Verified base: a2d8b94dd50dab8d966239dd63ab84718aa8b4b4 (fetched main).
|
||||
The change was reapplied without conflicts and relevant checks rerun.
|
||||
|
||||
- Focused existing/new parser, provider, service, contract and CLI/MCP tests:
|
||||
8 files / 56 tests passed.
|
||||
- After the final partial-uniqueness correction, reran service and compiled
|
||||
CLI/MCP boundary tests: 2 files / 5 tests passed.
|
||||
- `npm run check`: typecheck, lint, formatting, knip, module boundaries and
|
||||
architecture guards passed. Lint reports warnings; no errors.
|
||||
- `npm run docs:generate`, `npm run docs:check` and
|
||||
`npm run verify:test-discovery` passed; every test file was discovered exactly once.
|
||||
- Real WABT 1.0.42 comparison through compiled CLI and stdio MCP passed:
|
||||
equivalent padded LEB128 original encodings, function signatures, all admitted
|
||||
kinds, custom memory page size, escaped/BOM names, empty interfaces, explicit
|
||||
scope, duplicate imports and unadmitted exception reference signatures.
|
||||
- Existing real WABT artifact verifier passed after correcting its stale inline
|
||||
partial-observation assumption to resolve the MCP retained reference from the
|
||||
Evidence bundle and authenticate the original record.
|
||||
|
||||
## Independent review
|
||||
|
||||
Three findings repaired at their owning boundary:
|
||||
|
||||
1. Memory page size omitted: now preserved, default 65536 canonicalized.
|
||||
2. Shared signature expansion undercounted: charge every retained occurrence.
|
||||
Reviewer independently measured the 1000-params/10000-exports case: original
|
||||
262956-byte WAT produced 61824014-byte JSON before correction. It now retains
|
||||
1949 observations in a partial facet, with 12046676-byte JSON and an explicit
|
||||
capacity issue. Original WAT stays available; incomplete coverage prevents
|
||||
absence and uniqueness claims.
|
||||
3. Partial singleton uniqueness: comparison now returns unknown whenever either
|
||||
selected inventory is incomplete, including matched names.
|
||||
|
||||
Final independent source review reported no actionable findings. Review does not
|
||||
replace the executed tests or real-tool evidence.
|
||||
|
||||
## Scope and measurement limits
|
||||
|
||||
On the original implementation base f254f5a, the complete comparison verifier reported 7.98 seconds elapsed, 6.56 user and
|
||||
3.13 system seconds, and 429441024 bytes maximum RSS via macOS `/usr/bin/time -l`.
|
||||
These are command-accounting measurements, not aggregate concurrent process-family
|
||||
memory or model tokens. Token usage and full process-family peak are unmeasured.
|
||||
Real-tool verification covers macOS arm64 only; Linux/Windows remain unverified.
|
||||
No module was instantiated/executed and no source was fetched. Static equality
|
||||
is not runtime compatibility. Full CI is separate from this local verification record.
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
harm_rank,location,trigger,current_copy,user_risk,proposed_replacement,implementation_status,verification_result
|
||||
5,src/domain/native/bytePattern.ts,Unconstrained byte signature,Pattern must constrain at least one bit,Low risk; identifies input correction,Keep current copy,implemented,parser and MCP validation tests
|
||||
5,src/domain/native/bytePattern.ts,Unequal mask length,Mask length must equal pattern length,Low risk; identifies input correction,Keep current copy,implemented,parser and MCP validation tests
|
||||
5,bridge/ghidra/ReaGhidraBridge.java:searchMemoryBytes,Cursor reused with another query,Byte search cursor does not match the target and query,Low risk; identifies continuation mismatch,Keep current copy,implemented,real-provider verification required
|
||||
1,src/domain/errors.ts:AnalysisCapabilityUnavailableError,Requested analysis unavailable,"Provider {providerId} cannot execute {operation}: {reason}",Exposes provider and internal operation/reason,"This analysis is unavailable for the current target. Choose another analysis or target.",implemented,focused assertions passed; full `npm run check` passed
|
||||
2,src/domain/errors.ts:ProviderAdapterError,Provider adapter fails,"Provider {providerId} adapter failed during {operation}",Exposes provider and implementation boundary,"Analysis could not complete. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed.",implemented,focused assertions passed; full `npm run check` passed
|
||||
3,src/domain/errors.ts:HopperRemoteError,Remote analysis request fails,"Hopper request failed ({code}): {safeMessage}",Exposes provider name code and upstream text,"Analysis could not complete. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed.",implemented,focused assertions passed; full `npm run check` passed
|
||||
|
|
|
|||
|
|
|
@ -270,6 +270,7 @@
|
|||
"verify:binary:layout": "npm run build:cached && node scripts/verify-binary-layout.mjs",
|
||||
"verify:go:binary": "npm run build:cached && node scripts/verify-go-binary.mjs",
|
||||
"verify:wasm:package": "npm run build:cached && node scripts/verify-packaged-wasm-artifact.mjs",
|
||||
"verify:wasm-interface-comparison": "npm run build:cached && node scripts/verify-wasm-interface-comparison.mjs",
|
||||
"verify:wasm:artifact": "npm run build:cached && node scripts/verify-wasm-artifact.mjs",
|
||||
"verify:evm:interface": "npm run build:cached && node scripts/verify-evm-interface.mjs",
|
||||
"verify:solidity:metadata": "npm run build:cached && node scripts/verify-solidity-metadata.mjs",
|
||||
|
|
|
|||
|
|
@ -213,7 +213,9 @@ export const providerCatalog = (sources) => {
|
|||
},
|
||||
{
|
||||
identity: sources.wasmProvider.WABT_PROVIDER_IDENTITY,
|
||||
contracts: sources.wasmContracts.WASM_TOOL_CONTRACTS,
|
||||
contracts: sources.wasmContracts.WASM_TOOL_CONTRACTS.filter(
|
||||
({ kind }) => kind === "artifact-provider",
|
||||
),
|
||||
},
|
||||
{
|
||||
identity: sources.evmProvider.EVMOLE_PROVIDER_IDENTITY,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
chmod,
|
||||
copyFile,
|
||||
|
|
@ -259,7 +260,7 @@ try {
|
|||
const execution = await runCodex(
|
||||
answerInstructions.length === 0
|
||||
? scenario.prompt
|
||||
: `${scenario.prompt}\n\nThis closed factual rubric needs complete producer Evidence in the transcript. Request complete detail on each initial application analysis when supported; summary or view records alone are not accepted by this rubric.\n\n${answerInstructions}`,
|
||||
: `${scenario.prompt}\n\nThis closed factual rubric needs complete producer Evidence in the transcript. Request complete detail on each initial inspection or analysis when supported; summary or view records alone are not accepted by this rubric.\n\n${answerInstructions}`,
|
||||
scenario.approvedTools ?? [],
|
||||
);
|
||||
const transcriptDirectory = process.env.REA_AGENT_EVAL_TRANSCRIPT_DIR;
|
||||
|
|
@ -440,6 +441,13 @@ async function createTargets(root, includeManaged) {
|
|||
...fixtures,
|
||||
largeJavascript,
|
||||
managed: join(managedOutput, "AgentEval.dll"),
|
||||
...(includeManaged
|
||||
? {
|
||||
managedSha256: createHash("sha256")
|
||||
.update(await readFile(join(managedOutput, "AgentEval.dll")))
|
||||
.digest("hex"),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -114,6 +114,22 @@ try {
|
|||
length: bytes.length,
|
||||
});
|
||||
assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex"));
|
||||
const signature = await call("search_bytes", {
|
||||
pattern: "1e ?? 2f e1",
|
||||
limit: 1000,
|
||||
});
|
||||
assert.equal(signature.normalized_result.complete, true);
|
||||
assert.equal(signature.normalized_result.next_cursor, null);
|
||||
assert.equal(signature.normalized_result.searched.bytes, bytes.length);
|
||||
assert.deepEqual(
|
||||
signature.normalized_result.matches.map((match) => match.address),
|
||||
[entry],
|
||||
);
|
||||
assert.equal(signature.normalized_result.matches[0].file_offset, 0);
|
||||
assert.equal(
|
||||
signature.normalized_result.matches[0].procedure.address,
|
||||
entry,
|
||||
);
|
||||
const procedures = await call("list_procedures");
|
||||
assert.ok(
|
||||
procedures.normalized_result.some(
|
||||
|
|
@ -211,6 +227,69 @@ try {
|
|||
return_42: true,
|
||||
});
|
||||
}
|
||||
const repeatedPath = join(workspace, "signatures.bin");
|
||||
const repeatedBytes = Buffer.from("b82a000000c3".repeat(1005), "hex");
|
||||
const repeatedSha256 = createHash("sha256")
|
||||
.update(repeatedBytes)
|
||||
.digest("hex");
|
||||
await writeFile(repeatedPath, repeatedBytes);
|
||||
await call("open_binary", {
|
||||
path: repeatedPath,
|
||||
format: "raw-binary",
|
||||
architecture: "x86_64",
|
||||
entry_point: "0x1000",
|
||||
});
|
||||
// Import/analysis is complete before timing the first uncached search.
|
||||
await call("read_bytes", { address: "0x1000", length: 6 }, repeatedSha256);
|
||||
const started = performance.now();
|
||||
const page = await call(
|
||||
"search_bytes",
|
||||
{ pattern: "b8 ?? 00 00 00 c3", limit: 1000 },
|
||||
repeatedSha256,
|
||||
);
|
||||
const searchElapsedMs = performance.now() - started;
|
||||
assert.equal(page.normalized_result.matches.length, 1000);
|
||||
assert.equal(page.normalized_result.complete, false);
|
||||
const rest = await call(
|
||||
"search_bytes",
|
||||
{
|
||||
pattern: "b8 ?? 00 00 00 c3",
|
||||
limit: 1000,
|
||||
cursor: page.normalized_result.next_cursor,
|
||||
},
|
||||
repeatedSha256,
|
||||
);
|
||||
assert.equal(rest.normalized_result.matches.length, 5);
|
||||
assert.equal(
|
||||
rest.normalized_result.matches[0].address,
|
||||
`0x${(0x1000 + 6000).toString(16)}`,
|
||||
);
|
||||
assert.equal(rest.normalized_result.complete, true);
|
||||
const masked = await call(
|
||||
"search_bytes",
|
||||
{ pattern: "bf", mask: "f0", start: "0x1000", end: "0x1005" },
|
||||
repeatedSha256,
|
||||
);
|
||||
assert.deepEqual(
|
||||
masked.normalized_result.matches.map((match) => match.address),
|
||||
["0x1000"],
|
||||
);
|
||||
const overlapping = await call(
|
||||
"search_bytes",
|
||||
{ pattern: "00 00", start: "0x1000", end: "0x1005" },
|
||||
repeatedSha256,
|
||||
);
|
||||
assert.deepEqual(
|
||||
overlapping.normalized_result.matches.map((match) => match.address),
|
||||
["0x1002", "0x1003"],
|
||||
);
|
||||
const wrongCursor = await client.callTool({
|
||||
name: "search_bytes",
|
||||
arguments: { pattern: "c3", cursor: page.normalized_result.next_cursor },
|
||||
});
|
||||
assert.equal(wrongCursor.isError, true);
|
||||
await call("close_binary");
|
||||
assert.deepEqual(await readFile(repeatedPath), repeatedBytes);
|
||||
report = {
|
||||
ok: true,
|
||||
mocked: false,
|
||||
|
|
@ -231,6 +310,15 @@ try {
|
|||
load_images_verified: true,
|
||||
original_unchanged: true,
|
||||
x86_targets: x86Targets,
|
||||
byte_search: {
|
||||
matches: 1005,
|
||||
page_size: 1000,
|
||||
first_search_elapsed_ms: searchElapsedMs,
|
||||
timing_scope: "MCP request after import; first uncached search",
|
||||
token_usage: "unmeasured",
|
||||
process_family_cpu_time: "unmeasured",
|
||||
peak_process_family_memory: "unmeasured",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (stderr.trim()) process.stderr.write(stderr);
|
||||
|
|
|
|||
|
|
@ -310,7 +310,23 @@ try {
|
|||
}),
|
||||
);
|
||||
assert.equal(error.category, "invalid_input");
|
||||
const failed = parseEvidence(error.details.partial_observation);
|
||||
const partial = error.details.partial_observation;
|
||||
assert.equal(partial.kind, "retained-evidence");
|
||||
const failedBundleReply = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
assert.notEqual(
|
||||
failedBundleReply.isError,
|
||||
true,
|
||||
mcpTextValue(failedBundleReply),
|
||||
);
|
||||
const failedBundle = JSON.parse(mcpTextValue(failedBundleReply)).result;
|
||||
const failed = parseEvidence(
|
||||
failedBundle.records.find(
|
||||
({ evidence_id }) => evidence_id === partial.evidence_id,
|
||||
),
|
||||
);
|
||||
assert.equal(failed.subject.digest.sha256, sha256(invalidBytes));
|
||||
assert.equal(
|
||||
failed.normalized_result.validation,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,208 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises";
|
||||
import { join, resolve, isAbsolute } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const bin = process.env.REA_WABT_BIN_DIRECTORY;
|
||||
if (!isAbsolute(bin ?? ""))
|
||||
throw new Error("Supply absolute REA_WABT_BIN_DIRECTORY with WABT 1.0.42");
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-wasm-interface-real-"));
|
||||
const cli = resolve("scripts/rea.mjs");
|
||||
const environment = { ...process.env };
|
||||
const command = async (file, args) =>
|
||||
(
|
||||
await execute(file, args, {
|
||||
env: environment,
|
||||
cwd: root,
|
||||
timeout: 120000,
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
})
|
||||
).stdout;
|
||||
const client = new Client({
|
||||
name: "wasm-interface-real-verifier",
|
||||
version: "1",
|
||||
});
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [cli, "mcp"],
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const source = (parameter) =>
|
||||
`(module (type (func (param ${parameter}) (result i64))) (import "env" "f" (func (type 0))) (table 1 3 funcref) (memory 1 2) (global (mut i64) (i64.const 0)) (tag (param i32)) (export "run\\22\\ef\\bb\\bf" (func 0)) (export "table" (table 0)) (export "memory" (memory 0)) (export "global" (global 0)) (export "tag" (tag 0)))`;
|
||||
const assemble = async (name, wat) => {
|
||||
await writeFile(join(root, `${name}.wat`), wat);
|
||||
const path = join(root, `${name}.wasm`);
|
||||
await command(join(bin, "wat2wasm"), [
|
||||
"--enable-all",
|
||||
`${name}.wat`,
|
||||
"-o",
|
||||
path,
|
||||
]);
|
||||
return path;
|
||||
};
|
||||
const inspect = async (path) => {
|
||||
const result = await client.callTool({
|
||||
name: "inspect_wasm_artifact",
|
||||
arguments: { path },
|
||||
});
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
return parseEvidence(result.structuredContent);
|
||||
};
|
||||
const compare = async (left, right, scope = "both") => {
|
||||
const input = {
|
||||
left: { kind: "retained-evidence", evidence_id: left.evidence_id },
|
||||
right: { kind: "retained-evidence", evidence_id: right.evidence_id },
|
||||
scope,
|
||||
};
|
||||
const reply = await client.callTool({
|
||||
name: "compare_wasm_interfaces",
|
||||
arguments: input,
|
||||
});
|
||||
assert.notEqual(reply.isError, true, JSON.stringify(reply));
|
||||
const mcp = parseEvidence(reply.structuredContent);
|
||||
const file = join(root, "comparison.json");
|
||||
await writeFile(file, JSON.stringify({ left, right, scope }));
|
||||
const local = parseEvidence(
|
||||
JSON.parse(
|
||||
await command(process.execPath, [
|
||||
cli,
|
||||
"compare-wasm-interfaces",
|
||||
file,
|
||||
"--json",
|
||||
]),
|
||||
),
|
||||
);
|
||||
assert.deepEqual(local, mcp);
|
||||
return mcp.normalized_result;
|
||||
};
|
||||
try {
|
||||
assert.equal(
|
||||
(await command(join(bin, "wat2wasm"), ["--version"])).trim(),
|
||||
"1.0.42",
|
||||
);
|
||||
await client.connect(transport);
|
||||
const path = await assemble("baseline", source("i32"));
|
||||
const original = await readFile(path);
|
||||
assert.equal(original[8], 1);
|
||||
assert.ok(original[9] < 128);
|
||||
// Section lengths allow padded u32 LEB128 encodings. Different original
|
||||
// byte identities must compare equal after validated type resolution.
|
||||
const equivalentPath = join(root, "equivalent.wasm");
|
||||
await writeFile(
|
||||
equivalentPath,
|
||||
Buffer.concat([
|
||||
original.subarray(0, 9),
|
||||
Buffer.from([original[9] | 128, 0]),
|
||||
original.subarray(10),
|
||||
]),
|
||||
);
|
||||
const left = await inspect(path);
|
||||
const equivalent = await inspect(equivalentPath);
|
||||
assert.notEqual(left.subject.digest.sha256, equivalent.subject.digest.sha256);
|
||||
const same = await compare(left, equivalent);
|
||||
assert.deepEqual(same.summary, {
|
||||
added: 0,
|
||||
removed: 0,
|
||||
changed: 0,
|
||||
unchanged: 6,
|
||||
unknown: 0,
|
||||
});
|
||||
const facts = left.normalized_result.interface.entries;
|
||||
assert.deepEqual(facts.find((e) => e.name === "memory").signature, {
|
||||
kind: "memory",
|
||||
page_size: "65536",
|
||||
address_type: "i32",
|
||||
minimum: "1",
|
||||
maximum: "2",
|
||||
shared: false,
|
||||
});
|
||||
assert.deepEqual(facts.find((e) => e.name === "tag").signature, {
|
||||
kind: "tag",
|
||||
params: ["i32"],
|
||||
results: [],
|
||||
});
|
||||
assert.deepEqual(facts.find((e) => e.name === 'run"\ufeff').signature, {
|
||||
kind: "func",
|
||||
params: ["i32"],
|
||||
results: ["i64"],
|
||||
});
|
||||
const changed = await inspect(await assemble("changed", source("i64")));
|
||||
assert.deepEqual((await compare(left, changed)).summary, {
|
||||
added: 0,
|
||||
removed: 0,
|
||||
changed: 2,
|
||||
unchanged: 4,
|
||||
unknown: 0,
|
||||
});
|
||||
const smallPage = await inspect(
|
||||
await assemble(
|
||||
"small-page",
|
||||
'(module (memory 1 2 (pagesize 1)) (export "memory" (memory 0)))',
|
||||
),
|
||||
);
|
||||
const defaultPage = await inspect(
|
||||
await assemble(
|
||||
"default-page",
|
||||
'(module (memory 1 2) (export "memory" (memory 0)))',
|
||||
),
|
||||
);
|
||||
assert.equal((await compare(defaultPage, smallPage)).summary.changed, 1);
|
||||
const empty = await inspect(await assemble("empty", "(module)"));
|
||||
assert.equal((await compare(left, empty)).summary.removed, 6);
|
||||
assert.equal((await compare(empty, left, "exports")).summary.added, 5);
|
||||
const duplicate = await inspect(
|
||||
await assemble(
|
||||
"duplicate",
|
||||
'(module (import "e" "same" (func (param i32))) (import "e" "same" (func (param i64))))',
|
||||
),
|
||||
);
|
||||
const duplicates = await compare(duplicate, duplicate, "imports");
|
||||
assert.equal(duplicates.summary.unknown, 1);
|
||||
assert.equal(duplicates.changes[0].left.length, 2);
|
||||
const unsupported = await inspect(
|
||||
await assemble(
|
||||
"exnref",
|
||||
'(module (import "e" "abstract-ref" (global exnref)))',
|
||||
),
|
||||
);
|
||||
assert.equal((await compare(unsupported, unsupported)).summary.unknown, 1);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
verified: true,
|
||||
toolchain: "WABT 1.0.42",
|
||||
transport: "compiled CLI + stdio MCP",
|
||||
interface_fact_bytes: Buffer.byteLength(
|
||||
JSON.stringify(left.normalized_result.interface),
|
||||
),
|
||||
comparison_result_bytes: Buffer.byteLength(JSON.stringify(same)),
|
||||
original_sha256: left.subject.digest.sha256,
|
||||
equivalent_sha256: equivalent.subject.digest.sha256,
|
||||
cases: [
|
||||
"equivalent padded LEB128",
|
||||
"function signature change",
|
||||
"all admitted kinds",
|
||||
"custom memory page-size change",
|
||||
"escaped/BOM name",
|
||||
"empty interface additions/removals",
|
||||
"scope",
|
||||
"duplicate imports",
|
||||
"unsupported exception reference",
|
||||
],
|
||||
runtime_execution: "not-performed",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -89,6 +89,30 @@ check(
|
|||
["src/domain/javascript/javascriptSemanticTraversal.ts"],
|
||||
);
|
||||
|
||||
// A producer that pins both the soft and hard ceiling of every resource must take
|
||||
// the vector from one owner. Audited 2026-10-13 against the other prlimit lanes:
|
||||
// src/pcap/PcapNativeBound.ts pairs soft and hard but budgets 300 CPU seconds and
|
||||
// has no resident-input ceiling; src/evm/EvmoleInterfaceProvider.ts,
|
||||
// src/firmware/FirmwareCommand.ts and src/javascript/recovery/WakaruCommand.ts
|
||||
// spell soft-only forms (`--core=0:`, `--core=0`). Those four stay as they are;
|
||||
// a new paired producer calls prlimitArgumentVector instead of spelling options.
|
||||
check(
|
||||
"inline paired prlimit ceilings (use prlimitArgumentVector from src/process/LinuxResourceLimits)",
|
||||
files,
|
||||
/--core=0:0/,
|
||||
["src/process/LinuxResourceLimits.ts", "src/pcap/PcapNativeBound.ts"],
|
||||
);
|
||||
|
||||
// Census is spelling-shaped, like the rules above: a lane that expresses the same
|
||||
// ceilings differently (`1024 ** 3`, camelCase fields) is not caught here, which is
|
||||
// why the four lanes above are listed rather than left to a future reader.
|
||||
check(
|
||||
"inline resident-input budget (use BOUNDED_PRODUCER_LIMITS from src/process/LinuxResourceLimits)",
|
||||
files,
|
||||
/input_bytes:\s*32 \* 1024 \* 1024/,
|
||||
["src/process/LinuxResourceLimits.ts"],
|
||||
);
|
||||
|
||||
// Fixed-arity backtick keys audited 2026-10-10: every prefix field is NUL-free
|
||||
// (hex digests, file paths, enums, numbers, or compositeKey JSON which escapes
|
||||
// NUL), with free-form source strings only in final position. New backtick-\0
|
||||
|
|
|
|||
|
|
@ -1127,6 +1127,68 @@ exit 1`,
|
|||
expect(projected.message).toContain("not installed for 0");
|
||||
});
|
||||
|
||||
// adb exits 1 on a refused package change: `adb install` after printing the
|
||||
// status line to stderr, `cmd package uninstall` after printing it to stdout.
|
||||
it("preserves the device's install refusal when adb exits nonzero", async () => {
|
||||
const { binary, root } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ] && [ "$3" = "install" ]; then
|
||||
printf 'Performing Streamed Install\\n'
|
||||
printf 'adb: failed to install app.apk: Failure [INSTALL_FAILED_ALREADY_EXISTS: Attempt to re-install com.example without first uninstalling.]\\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 1`,
|
||||
);
|
||||
const apk = join(root, "app.apk");
|
||||
await writeFile(apk, "apk");
|
||||
const projected = await refusedBy(providerOver(binary), {
|
||||
operation: "install_adb_package",
|
||||
input: { serial: "emulator-5554", apk_path: apk, replace: false },
|
||||
});
|
||||
expect(projected).toMatchObject({
|
||||
code: "unsupported_target",
|
||||
details: {
|
||||
reason:
|
||||
"INSTALL_FAILED_ALREADY_EXISTS: Attempt to re-install com.example without first uninstalling.",
|
||||
},
|
||||
});
|
||||
expect(projected.message).toContain(
|
||||
"INSTALL_FAILED_ALREADY_EXISTS: Attempt to re-install com.example without first uninstalling.",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the device's uninstall refusal when adb exits nonzero", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ] && [ "$3" = "uninstall" ]; then printf 'Failure [not installed for 0]\\n'; exit 1; fi
|
||||
exit 1`,
|
||||
);
|
||||
const projected = await refusedBy(providerOver(binary), {
|
||||
operation: "uninstall_adb_package",
|
||||
input: { serial: "emulator-5554", package: "com.absent" },
|
||||
});
|
||||
expect(projected).toMatchObject({
|
||||
code: "unsupported_target",
|
||||
details: { reason: "not installed for 0" },
|
||||
});
|
||||
expect(projected.message).toContain("not installed for 0");
|
||||
});
|
||||
|
||||
it("keeps a nonzero install without a device verdict as an unreadable result", async () => {
|
||||
const { binary, root } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ] && [ "$3" = "install" ]; then printf 'adb: failed to install app.apk: closed\\n' >&2; exit 1; fi
|
||||
exit 1`,
|
||||
);
|
||||
const apk = join(root, "app.apk");
|
||||
await writeFile(apk, "apk");
|
||||
const projected = await refusedBy(providerOver(binary), {
|
||||
operation: "install_adb_package",
|
||||
input: { serial: "emulator-5554", apk_path: apk, replace: false },
|
||||
});
|
||||
expect(projected).toMatchObject({ code: "unreadable_output" });
|
||||
});
|
||||
|
||||
it("starts an app through resolved launcher component and force-stops it", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
shellStub({
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ import {
|
|||
parseBugreportCopiedPath,
|
||||
parseDumpsysPackageOutput,
|
||||
parseFeatureListOutput,
|
||||
packageChangeFailureReason,
|
||||
parsePackageChangeOutput,
|
||||
parseProcessListOutput,
|
||||
parsePngDimensions,
|
||||
|
|
@ -248,6 +249,31 @@ const runAdb = async (
|
|||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* adb exits nonzero when the device refuses a package change, printing the
|
||||
* `Failure [...]` verdict to stderr (install) or stdout (uninstall). Read the
|
||||
* refusal from that failed run so it is not reported as unreadable output.
|
||||
*/
|
||||
const packageChangeRefusal = (
|
||||
operation: string,
|
||||
serial: string,
|
||||
error: AnalysisError,
|
||||
): Result<never, AnalysisError> | undefined => {
|
||||
if (!(error instanceof AnalysisProtocolError)) return undefined;
|
||||
const failure = execFileOutputFailure(error.cause);
|
||||
if (failure === undefined) return undefined;
|
||||
const reason = packageChangeFailureReason(
|
||||
`${failure.stdout}\n${failure.stderr}`,
|
||||
);
|
||||
return reason === null
|
||||
? undefined
|
||||
: err(
|
||||
new AnalysisUnsupportedTargetError(operation, serial, reason, {
|
||||
cause: error,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const clientIdentityResult = (
|
||||
selection: AdbBinarySelection,
|
||||
identity: AdbClientIdentity,
|
||||
|
|
@ -1754,7 +1780,11 @@ export class AdbProvider {
|
|||
{ timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES },
|
||||
signal,
|
||||
);
|
||||
if (!installed.ok) return installed;
|
||||
if (!installed.ok)
|
||||
return (
|
||||
packageChangeRefusal(operation, input.serial, installed.error) ??
|
||||
installed
|
||||
);
|
||||
const verdict = parsePackageChangeOutput(installed.value.stdout);
|
||||
if (verdict.status === "failure")
|
||||
return err(
|
||||
|
|
@ -1803,7 +1833,11 @@ export class AdbProvider {
|
|||
},
|
||||
signal,
|
||||
);
|
||||
if (!uninstalled.ok) return uninstalled;
|
||||
if (!uninstalled.ok)
|
||||
return (
|
||||
packageChangeRefusal(operation, input.serial, uninstalled.error) ??
|
||||
uninstalled
|
||||
);
|
||||
const verdict = parsePackageChangeOutput(uninstalled.value.stdout);
|
||||
if (verdict.status === "failure")
|
||||
return err(
|
||||
|
|
|
|||
|
|
@ -293,6 +293,10 @@ export const parseBugreportCopiedPath = (stdout: string): string | null => {
|
|||
return match === null ? null : match[1]!;
|
||||
};
|
||||
|
||||
/** Read the device's `Failure [REASON]` verdict from package-change output. */
|
||||
export const packageChangeFailureReason = (output: string): string | null =>
|
||||
/Failure \[([^\]]+)\]/u.exec(output)?.[1] ?? null;
|
||||
|
||||
/**
|
||||
* Parse `adb install`/`adb uninstall` output. adb can print `Failure [...]`
|
||||
* with a zero exit code, so the text decides, not the exit status.
|
||||
|
|
@ -305,10 +309,11 @@ export const parsePackageChangeOutput = (
|
|||
} => {
|
||||
const text = stdout.trim();
|
||||
if (/^Success$/mu.test(text)) return { status: "success", reason: null };
|
||||
const failure = /Failure \[([^\]]+)\]/u.exec(text);
|
||||
return {
|
||||
status: "failure",
|
||||
reason: failure?.[1] ?? (text === "" ? null : text.slice(0, 512)),
|
||||
reason:
|
||||
packageChangeFailureReason(text) ??
|
||||
(text === "" ? null : text.slice(0, 512)),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveAppBundleExecutable } from "./AppBundleExecutable.js";
|
||||
|
||||
describe("app bundle Info.plist magic", () => {
|
||||
it("does not decode a high-bit fold of bplist as a binary Info.plist", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-bplist-fold-"));
|
||||
try {
|
||||
const bundle = join(root, "Fixture.app");
|
||||
const plistPath = join(bundle, "Contents", "Info.plist");
|
||||
await mkdir(join(bundle, "Contents"), { recursive: true });
|
||||
const plist = Buffer.from("bplist00");
|
||||
plist[0] = (plist[0] ?? 0) | 0x80;
|
||||
await writeFile(plistPath, plist);
|
||||
let decoded = false;
|
||||
const result = await resolveAppBundleExecutable(bundle, {
|
||||
readFile: async () => plist,
|
||||
realpath: async (path) => path,
|
||||
decodeBinaryPlist: async () => {
|
||||
decoded = true;
|
||||
return "Fixture";
|
||||
},
|
||||
});
|
||||
expect(decoded).toBe(false);
|
||||
expect(result.ok).toBe(false);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -162,6 +162,12 @@ export const parseBinaryTarget = async (
|
|||
availableArchitectures: ["x86"],
|
||||
});
|
||||
}
|
||||
// Probe WASM content before suffix-only database routing; an explicit
|
||||
// database selection keeps the caller's chosen interpretation.
|
||||
const artifactFormat =
|
||||
targetKind === "database"
|
||||
? undefined
|
||||
: await detectArtifactFormat(path, handle, signal);
|
||||
if (
|
||||
targetKind === "database" ||
|
||||
(targetKind === undefined && path.toLowerCase().endsWith(".hop"))
|
||||
|
|
@ -173,7 +179,6 @@ export const parseBinaryTarget = async (
|
|||
kind: "database",
|
||||
format: "analysis-database",
|
||||
});
|
||||
const artifactFormat = await detectArtifactFormat(path, handle, signal);
|
||||
if (artifactFormat !== undefined) {
|
||||
const identity = {
|
||||
path,
|
||||
|
|
|
|||
|
|
@ -110,9 +110,12 @@ const parseDocument = (
|
|||
return requireObject(parseToml(text), "The configuration root");
|
||||
// Pi loads `mcp.json` with a strict `JSON.parse` and no BOM tolerance, so
|
||||
// comments, trailing commas, a BOM, or an empty existing file must be
|
||||
// rejected instead of registering into a document Pi cannot load.
|
||||
if (format === "pi")
|
||||
// rejected instead of registering into a document the client cannot load.
|
||||
// Kimi also reads mcp.json with JSON.parse and an empty-file fallback.
|
||||
if (format === "pi" || format === "kimi") {
|
||||
if (format === "kimi" && text.trim().length === 0) return {};
|
||||
return requireObject(JSON.parse(text), "The configuration root");
|
||||
}
|
||||
// An empty file, which some clients create before any server is added,
|
||||
// holds no settings to preserve.
|
||||
if (isEmptyClientConfigurationText(text)) return {};
|
||||
|
|
@ -1446,6 +1449,13 @@ export const clientRegistrationEntry = (
|
|||
? {}
|
||||
: { environment: { ...environment } }),
|
||||
};
|
||||
case "kimi":
|
||||
// Kimi infers stdio from command; it uses `transport`, not `type`.
|
||||
return {
|
||||
command: executable,
|
||||
args,
|
||||
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
|
||||
};
|
||||
case "opencode_v2":
|
||||
// V2 connects servers unless `disabled` is true.
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ const registrationSchema = z
|
|||
command: z.string().min(1),
|
||||
args: z.array(z.string()).default([]),
|
||||
startup_timeout_sec: z.number().positive().optional(),
|
||||
startupTimeoutMs: z.number().positive().optional(),
|
||||
toolTimeoutMs: z.number().positive().optional(),
|
||||
type: z.string().optional(),
|
||||
transport: z.string().optional(),
|
||||
serverName: z.string().optional(),
|
||||
|
|
@ -258,6 +260,21 @@ const registrationAligned = (
|
|||
MCP_STARTUP_POLICY.codexStartupTimeoutSeconds
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
client.name === "kimi" &&
|
||||
(registration.startupTimeoutMs !==
|
||||
MCP_STARTUP_POLICY.kimiStartupTimeoutMs ||
|
||||
registration.toolTimeoutMs !== MCP_STARTUP_POLICY.kimiToolTimeoutMs)
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
(client.format === "kimi" ||
|
||||
client.format === "omp" ||
|
||||
client.format === "pi") &&
|
||||
registration.type !== undefined &&
|
||||
registration.type !== "stdio"
|
||||
)
|
||||
return false;
|
||||
if (client.format === "vscode" && registration.type !== "stdio") return false;
|
||||
// A Harness registration only connects as the rea stdio server; another
|
||||
// server name or transport leaves the session without REA tools.
|
||||
|
|
@ -266,13 +283,6 @@ const registrationAligned = (
|
|||
(registration.serverName !== "rea" || registration.transport !== "stdio")
|
||||
)
|
||||
return false;
|
||||
// OMP and Pi infer stdio for a command entry without an explicit type.
|
||||
if (
|
||||
(client.format === "omp" || client.format === "pi") &&
|
||||
registration.type !== undefined &&
|
||||
registration.type !== "stdio"
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
client.format === "copilot_cli" &&
|
||||
(registration.type !== "stdio" ||
|
||||
|
|
@ -331,6 +341,12 @@ const parseRegistration = (
|
|||
});
|
||||
}
|
||||
const registration = registrationSchema.parse(value);
|
||||
if (
|
||||
client.format === "kimi" &&
|
||||
registration.transport !== undefined &&
|
||||
registration.transport !== "stdio"
|
||||
)
|
||||
throw new TypeError("Expected an stdio registration");
|
||||
if (
|
||||
(client.format === "vscode" || client.format === "copilot_cli") &&
|
||||
registration.type !== "stdio"
|
||||
|
|
@ -339,7 +355,9 @@ const parseRegistration = (
|
|||
if (client.format === "commandcode" && registration.transport !== "stdio")
|
||||
throw new TypeError("Expected an stdio registration");
|
||||
if (
|
||||
(client.format === "omp" || client.format === "pi") &&
|
||||
(client.format === "kimi" ||
|
||||
client.format === "omp" ||
|
||||
client.format === "pi") &&
|
||||
registration.type !== undefined &&
|
||||
registration.type !== "stdio"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ type DirectAnalysisTool =
|
|||
| "annotate_native_function"
|
||||
| "inspect_native_load_image"
|
||||
| "read_bytes"
|
||||
| "search_bytes"
|
||||
| "address_to_file_offset"
|
||||
| "binary_overview"
|
||||
| "procedure_pseudo_code"
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ describe("doctor", () => {
|
|||
),
|
||||
).toEqual({
|
||||
node: `Install ${SUPPORTED_NODE_VERSION_PROSE}.`,
|
||||
host: "REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, Omarchy, and the experimental Windows x64 Ghidra P0 boundary.",
|
||||
host: "REA supports macOS 12+, Linux distributions with a recognized apt, dnf, or pacman package family, and the experimental Windows x64 Ghidra P0 boundary; automated Hopper setup additionally requires Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy.",
|
||||
hopper: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",
|
||||
target: "Supply a readable local app or program path.",
|
||||
});
|
||||
|
|
@ -418,10 +418,9 @@ describe("doctor scoped readiness", () => {
|
|||
platform: "linux",
|
||||
linuxDistribution: () =>
|
||||
Promise.resolve({
|
||||
id: "debian",
|
||||
versionId: "13",
|
||||
packageFamily: "deb",
|
||||
supported: false,
|
||||
id: "nixos",
|
||||
packageFamily: undefined,
|
||||
hopperSetupSupported: false,
|
||||
}),
|
||||
clientRegistrations: () =>
|
||||
Promise.resolve([
|
||||
|
|
@ -451,6 +450,31 @@ describe("doctor scoped readiness", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("accepts a Debian-family host that has no tested Hopper package", async () => {
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
host({
|
||||
platform: "linux",
|
||||
macosVersion: () => Promise.resolve(undefined),
|
||||
linuxDistribution: () =>
|
||||
Promise.resolve({
|
||||
id: "kali",
|
||||
versionId: "2026.3",
|
||||
packageFamily: "deb",
|
||||
hopperSetupSupported: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.checks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "host",
|
||||
ok: true,
|
||||
detail: "kali 2026.3",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires explicitly selected providers instead of any available provider", async () => {
|
||||
const ghidra = {
|
||||
id: "ghidra",
|
||||
|
|
@ -502,7 +526,7 @@ describe("doctor Linux and Hopper discovery", () => {
|
|||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
supported: true,
|
||||
hopperSetupSupported: true,
|
||||
}),
|
||||
configuredHopperPath: path,
|
||||
executable: (candidate) => Promise.resolve(candidate === path),
|
||||
|
|
@ -522,7 +546,7 @@ describe("doctor Linux and Hopper discovery", () => {
|
|||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
supported: true,
|
||||
hopperSetupSupported: true,
|
||||
}),
|
||||
linuxDemoRuntimeCheck: () =>
|
||||
Promise.resolve({
|
||||
|
|
@ -554,7 +578,7 @@ describe("doctor Linux and Hopper discovery", () => {
|
|||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
supported: true,
|
||||
hopperSetupSupported: true,
|
||||
}),
|
||||
executable: (candidate) => Promise.resolve(candidate === path),
|
||||
supportedLinuxHopper: () => Promise.resolve(false),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import {
|
|||
SUPPORTED_NODE_VERSION_PROSE,
|
||||
supportsNodeVersion,
|
||||
} from "../domain/runtimeVersion.js";
|
||||
import { linuxHopperLauncherPath } from "./LinuxHopper.js";
|
||||
import { linuxHopperLauncherPath, supportsReaHost } from "./LinuxHopper.js";
|
||||
import type {
|
||||
DoctorCheck,
|
||||
DoctorHost,
|
||||
|
|
@ -129,7 +129,7 @@ const hostCheck = async (host: DoctorHost): Promise<DoctorCheck> => {
|
|||
host.platform === "linux" ? await host.linuxDistribution() : undefined;
|
||||
const supported =
|
||||
(host.platform === "darwin" && parseMajor(macosVersion) >= 12) ||
|
||||
(host.platform === "linux" && linuxDistribution?.supported === true) ||
|
||||
(host.platform === "linux" && supportsReaHost(linuxDistribution)) ||
|
||||
(host.platform === "win32" && host.architecture === "x64");
|
||||
const detail =
|
||||
macosVersion ??
|
||||
|
|
@ -138,7 +138,7 @@ const hostCheck = async (host: DoctorHost): Promise<DoctorCheck> => {
|
|||
: `${linuxDistribution.id} ${linuxDistribution.versionId ?? "unknown"}`);
|
||||
return check("host", supported, detail, {
|
||||
remediation:
|
||||
"REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, Omarchy, and the experimental Windows x64 Ghidra P0 boundary.",
|
||||
"REA supports macOS 12+, Linux distributions with a recognized apt, dnf, or pacman package family, and the experimental Windows x64 Ghidra P0 boundary; automated Hopper setup additionally requires Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy.",
|
||||
classification: "unsupported_host",
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { CordisExpression, DshCordisYaml } from "./DshCordisYaml.js";
|
|||
export const DSH_ENTRY_ID = "rea-mcp";
|
||||
export const DSH_CLIENT_PACKAGE = "@deepseek-ai/dsh-mcp-client";
|
||||
const LAUNCHER_KEYS = new Set(["serverName", "transport", "command", "args"]);
|
||||
const SELECTOR_KEYS = new Set(["id", "name"]);
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
|
|
@ -295,10 +296,15 @@ export class DshCordisDocument {
|
|||
carried = true;
|
||||
return [this.#entry(desired, invalidated)] as typeof inserted.items;
|
||||
});
|
||||
replacements.set(
|
||||
op.source,
|
||||
inserted.items.length === 0 ? undefined : copy,
|
||||
);
|
||||
if (inserted.items.length === 0) copy.delete("insert");
|
||||
// An emptied operation keeps caller fields unless it would patch REA's row.
|
||||
const kept =
|
||||
inserted.items.length > 0 ||
|
||||
(this.#id(op.map) !== DSH_ENTRY_ID &&
|
||||
copy.items.some(
|
||||
(pair) => !SELECTOR_KEYS.has(this.#yaml.value(pair.key) as string),
|
||||
));
|
||||
replacements.set(op.source, kept ? copy : undefined);
|
||||
}
|
||||
const output = this.#yaml.document.clone();
|
||||
const outputRoot = output.contents;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
linuxPackageManagerCommands,
|
||||
linuxSharedLibrariesAvailable,
|
||||
parseLinuxDistribution,
|
||||
supportsReaHost,
|
||||
type LinuxDistribution,
|
||||
type LinuxHopperDownload,
|
||||
type LinuxHopperInstallHost,
|
||||
|
|
@ -19,7 +20,7 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
|
|||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
supported: true,
|
||||
hopperSetupSupported: true,
|
||||
};
|
||||
archive = new Uint8Array([1, 2, 3]);
|
||||
archiveOk = true;
|
||||
|
|
@ -66,7 +67,7 @@ describe("Linux Hopper host classification", () => {
|
|||
(document, family) => {
|
||||
expect(parseLinuxDistribution(document)).toMatchObject({
|
||||
packageFamily: family,
|
||||
supported: true,
|
||||
hopperSetupSupported: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
@ -99,8 +100,32 @@ describe("Linux Hopper host classification", () => {
|
|||
"ID=garuda\nID_LIKE=arch\n",
|
||||
"ID=omarchy\n",
|
||||
"ID=omarchy\nID_LIKE=debian\n",
|
||||
])("rejects unsupported vendor/version combinations", (document) => {
|
||||
expect(parseLinuxDistribution(document).supported).toBe(false);
|
||||
])(
|
||||
"rejects vendor/version combinations without a tested Hopper installer",
|
||||
(document) => {
|
||||
expect(parseLinuxDistribution(document).hopperSetupSupported).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
'ID=kali\nVERSION_ID="2026.3"\nID_LIKE=debian\n',
|
||||
'ID=debian\nVERSION_ID="13"\nID_LIKE=debian\n',
|
||||
'ID=linuxmint\nVERSION_ID="22.1"\nID_LIKE="ubuntu debian"\n',
|
||||
"ID=manjaro\nID_LIKE=arch\n",
|
||||
"ID=derivative\nID_LIKE='debian'\n",
|
||||
"ID='debian'\nVERSION_ID='13'\n",
|
||||
])(
|
||||
"hosts REA on a distribution Hopper has no tested package for: %s",
|
||||
(document) => {
|
||||
const distribution = parseLinuxDistribution(document);
|
||||
expect(distribution.hopperSetupSupported).toBe(false);
|
||||
expect(supportsReaHost(distribution)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("withholds host support from a distribution with no package family", () => {
|
||||
expect(supportsReaHost(parseLinuxDistribution("ID=nixos\n"))).toBe(false);
|
||||
expect(supportsReaHost(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -166,7 +191,7 @@ describe("Linux Hopper installation", () => {
|
|||
host.distributionValue = {
|
||||
id: family,
|
||||
packageFamily: family,
|
||||
supported: true,
|
||||
hopperSetupSupported: true,
|
||||
};
|
||||
const result = await installFixture(host);
|
||||
expect(result).toEqual({
|
||||
|
|
@ -194,7 +219,7 @@ describe("Linux Hopper installation", () => {
|
|||
host.distributionValue = {
|
||||
id: "debian",
|
||||
packageFamily: "deb",
|
||||
supported: false,
|
||||
hopperSetupSupported: false,
|
||||
};
|
||||
if (scenario === "package request failure") host.archiveOk = false;
|
||||
if (scenario === "checksum mismatch") host.archive = new Uint8Array([9]);
|
||||
|
|
|
|||
|
|
@ -76,19 +76,35 @@ interface LinuxDistributionIdentity {
|
|||
readonly versionId?: string;
|
||||
}
|
||||
|
||||
/** Parsed Linux distribution information used for support and package selection. */
|
||||
/**
|
||||
* Parsed Linux distribution information used for package selection.
|
||||
*
|
||||
* `hopperSetupSupported` is the automated Hopper installer's tested list. It is
|
||||
* narrower than the hosts where REA itself runs, which {@link supportsReaHost}
|
||||
* answers from the package family.
|
||||
*/
|
||||
export type LinuxDistribution = LinuxDistributionIdentity &
|
||||
(
|
||||
| {
|
||||
readonly packageFamily: LinuxPackageFamily;
|
||||
readonly supported: true;
|
||||
readonly hopperSetupSupported: true;
|
||||
}
|
||||
| {
|
||||
readonly packageFamily: LinuxPackageFamily | undefined;
|
||||
readonly supported: false;
|
||||
readonly hopperSetupSupported: false;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether REA's analysis providers run on this distribution. A recognized
|
||||
* package family means a glibc host with a known package layout, which is what
|
||||
* the providers need; downloading and installing the official Hopper package
|
||||
* additionally requires a distribution on its tested list.
|
||||
*/
|
||||
export const supportsReaHost = (
|
||||
distribution: LinuxDistribution | undefined,
|
||||
): boolean => distribution?.packageFamily !== undefined;
|
||||
|
||||
/** Download response kept independent of Node's global fetch implementation. */
|
||||
export interface LinuxHopperDownload {
|
||||
readonly ok: boolean;
|
||||
|
|
@ -141,16 +157,19 @@ export const parseLinuxDistribution = (text: string): LinuxDistribution => {
|
|||
const key = match[1];
|
||||
const encoded = match[2];
|
||||
if (key === undefined || encoded === undefined) continue;
|
||||
const value =
|
||||
encoded.startsWith('"') && encoded.endsWith('"')
|
||||
? encoded.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\")
|
||||
: encoded;
|
||||
const quote = encoded[0];
|
||||
const quoted = (quote === "'" || quote === '"') && encoded.endsWith(quote);
|
||||
const value = !quoted
|
||||
? encoded
|
||||
: quote === "'"
|
||||
? encoded.slice(1, -1)
|
||||
: encoded.slice(1, -1).replace(/\\(["\\$`])/gu, "$1");
|
||||
fields.set(key, value);
|
||||
}
|
||||
const id = (fields.get("ID") ?? "unknown").toLowerCase();
|
||||
const versionId = fields.get("VERSION_ID");
|
||||
const packageFamily = packageFamilyFor(id, fields.get("ID_LIKE"));
|
||||
const supported =
|
||||
const hopperSetupSupported =
|
||||
packageFamily !== undefined &&
|
||||
(id === "arch" ||
|
||||
id === "cachyos" ||
|
||||
|
|
@ -162,9 +181,9 @@ export const parseLinuxDistribution = (text: string): LinuxDistribution => {
|
|||
id,
|
||||
...(versionId === undefined ? {} : { versionId }),
|
||||
};
|
||||
return supported
|
||||
? { ...identity, packageFamily, supported: true }
|
||||
: { ...identity, packageFamily, supported: false };
|
||||
return hopperSetupSupported
|
||||
? { ...identity, packageFamily, hopperSetupSupported: true }
|
||||
: { ...identity, packageFamily, hopperSetupSupported: false };
|
||||
};
|
||||
|
||||
/** Read and classify the current Linux distribution. */
|
||||
|
|
@ -193,7 +212,7 @@ export const installLinuxHopper = async (
|
|||
if (options.signal?.aborted === true)
|
||||
return { status: "failed", reason: "cancelled" };
|
||||
const distribution = await host.distribution();
|
||||
if (distribution?.supported !== true)
|
||||
if (distribution?.hopperSetupSupported !== true)
|
||||
return { status: "failed", reason: "unsupported_host" };
|
||||
let temporary: string | undefined;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export const executeProviderOperation = async <
|
|||
provider: ProviderOperationPort<TRequest>,
|
||||
confidence: "observed" | "derived",
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
if (options?.signal?.aborted === true)
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
const request = schema.safeParse({ operation, input });
|
||||
if (!request.success)
|
||||
|
|
@ -51,8 +51,10 @@ export const executeProviderOperation = async <
|
|||
const executed = await provider.execute(request.data, options);
|
||||
if (!executed.ok) return executed;
|
||||
const execution = executed.value;
|
||||
return ok(
|
||||
createEvidence(execution.subject ?? undefined, execution.provider, {
|
||||
const evidence = createEvidence(
|
||||
execution.subject ?? undefined,
|
||||
execution.provider,
|
||||
{
|
||||
operation,
|
||||
parameters: jsonObjectSchema.parse(request.data.input),
|
||||
result: execution.result,
|
||||
|
|
@ -60,6 +62,16 @@ export const executeProviderOperation = async <
|
|||
limitations: execution.limitations,
|
||||
locations: execution.locations,
|
||||
confidence,
|
||||
}),
|
||||
},
|
||||
);
|
||||
// A provider that cannot observe the signal may finish its work after the
|
||||
// caller cancelled, so re-check rather than reporting a completed operation
|
||||
// the caller no longer wants. The observation it completed is still this
|
||||
// provider's Evidence, so the cancellation carries it as the partial
|
||||
// observation instead of discarding a finished result.
|
||||
if (options?.signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, { partialObservation: evidence }),
|
||||
);
|
||||
return ok(evidence);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ describe("setup workflow", () => {
|
|||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
supported: true,
|
||||
hopperSetupSupported: true,
|
||||
};
|
||||
host.hopper = "/opt/hopper/bin/Hopper";
|
||||
host.linuxDemoRuntimeMissing = true;
|
||||
|
|
@ -79,7 +79,7 @@ describe("setup workflow", () => {
|
|||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
supported: true,
|
||||
hopperSetupSupported: true,
|
||||
};
|
||||
host.hopper = "/custom/Hopper";
|
||||
host.unsupportedHopperVersion = true;
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ describe("setup workflow", () => {
|
|||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
supported: true,
|
||||
hopperSetupSupported: true,
|
||||
};
|
||||
const result = await runSetup(
|
||||
{ ...options(false), structured: false },
|
||||
|
|
@ -162,7 +162,7 @@ describe("setup workflow", () => {
|
|||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
supported: true,
|
||||
hopperSetupSupported: true,
|
||||
};
|
||||
const result = await runSetup(options(true, true), host);
|
||||
expect(result.appliedActions).toEqual(["installed_hopper"]);
|
||||
|
|
@ -236,7 +236,7 @@ describe("setup workflow lifecycle and provider planning", () => {
|
|||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
supported: true,
|
||||
hopperSetupSupported: true,
|
||||
};
|
||||
host.ghidra = "/opt/ghidra_12.1.4_PUBLIC";
|
||||
host.javaHome = "/usr/lib/jvm/jdk-21";
|
||||
|
|
|
|||
|
|
@ -310,7 +310,7 @@ describe("setup scoped readiness", () => {
|
|||
id: "debian",
|
||||
versionId: "13",
|
||||
packageFamily: "deb",
|
||||
supported: false,
|
||||
hopperSetupSupported: false,
|
||||
};
|
||||
const result = await runSetup(options(true, true), host);
|
||||
expect(result.status).toBe("needs_human");
|
||||
|
|
@ -326,7 +326,7 @@ describe("setup scoped readiness", () => {
|
|||
id: "debian",
|
||||
versionId: "13",
|
||||
packageFamily: "deb",
|
||||
supported: false,
|
||||
hopperSetupSupported: false,
|
||||
};
|
||||
host.ghidra = "/opt/ghidra_12.1.4_PUBLIC";
|
||||
host.javaHome = "/usr/lib/jvm/jdk-21";
|
||||
|
|
|
|||
|
|
@ -704,6 +704,14 @@ const clientConfigurationDesired = (
|
|||
launcher,
|
||||
environment.value,
|
||||
);
|
||||
const previous = parsed?.servers[PRODUCT_IDENTITY.mcpServerKey];
|
||||
if (
|
||||
client.name === "kimi" &&
|
||||
typeof previous === "object" &&
|
||||
previous !== null &&
|
||||
"enabledTools" in previous
|
||||
)
|
||||
registration.enabledTools = previous.enabledTools;
|
||||
return {
|
||||
...registration,
|
||||
...(client.name === "codex" || client.name === "grok_build"
|
||||
|
|
@ -711,5 +719,11 @@ const clientConfigurationDesired = (
|
|||
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
|
||||
}
|
||||
: {}),
|
||||
...(client.name === "kimi"
|
||||
? {
|
||||
startupTimeoutMs: MCP_STARTUP_POLICY.kimiStartupTimeoutMs,
|
||||
toolTimeoutMs: MCP_STARTUP_POLICY.kimiToolTimeoutMs,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -124,7 +124,8 @@ export const hostRemediation = async (
|
|||
? "Upgrade to macOS 12 or newer."
|
||||
: undefined;
|
||||
}
|
||||
if ((await host.linuxDistribution())?.supported === true) return undefined;
|
||||
if ((await host.linuxDistribution())?.hopperSetupSupported === true)
|
||||
return undefined;
|
||||
return "Automated Hopper setup supports Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, and Omarchy; configure an existing supported provider instead.";
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,8 @@ export const discoverSetupState = async (input: {
|
|||
const hopperInstallSupported =
|
||||
(input.host.platform === "darwin" &&
|
||||
majorVersion((await input.host.macosVersion()) ?? "0") >= 12) ||
|
||||
(input.host.platform === "linux" && linuxDistribution?.supported === true);
|
||||
(input.host.platform === "linux" &&
|
||||
linuxDistribution?.hopperSetupSupported === true);
|
||||
const installHopper =
|
||||
input.forceHopperInstall ||
|
||||
(hopperInstallSupported &&
|
||||
|
|
|
|||
|
|
@ -72,6 +72,15 @@ interface CanonicalSkillFile {
|
|||
readonly original: SkillFileState | undefined;
|
||||
}
|
||||
|
||||
const canonicalSkillContent = (relativePath: string): Promise<string> =>
|
||||
readFile(
|
||||
new URL(
|
||||
`../../skills/${PRODUCT_IDENTITY.skillName}/${relativePath}`,
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
export interface SkillInstallFileOperations {
|
||||
readonly mkdir: (path: string) => Promise<void>;
|
||||
readonly backup: (source: string, destination: string) => Promise<void>;
|
||||
|
|
@ -244,19 +253,36 @@ const canonicalSkillFiles = async (
|
|||
const destination = join(root, relativePath);
|
||||
return {
|
||||
destination,
|
||||
content: await readFile(
|
||||
new URL(
|
||||
`../../skills/${PRODUCT_IDENTITY.skillName}/${relativePath}`,
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
content: await canonicalSkillContent(relativePath),
|
||||
original: await readOptionalSkillState(destination, resources),
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const canonicalSkillNeedsInstallByReading = async (
|
||||
home: string,
|
||||
clientIds: readonly string[],
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
platform: NodeJS.Platform,
|
||||
resources?: ArtifactResourceScope,
|
||||
): Promise<boolean> => {
|
||||
for (const { path: root } of skillDestinations(
|
||||
home,
|
||||
clientIds,
|
||||
environment,
|
||||
platform,
|
||||
)) {
|
||||
for (const relativePath of MANAGED_SKILL_FILES) {
|
||||
const destination = join(root, relativePath);
|
||||
const content = await canonicalSkillContent(relativePath);
|
||||
const original = await readOptionalSkillState(destination, resources);
|
||||
if (original?.content !== content) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/** Report whether setup would change any file in the managed REA skill bundle. */
|
||||
export const canonicalSkillNeedsInstall = async (
|
||||
home: string,
|
||||
|
|
@ -266,15 +292,13 @@ export const canonicalSkillNeedsInstall = async (
|
|||
resources?: ArtifactResourceScope,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
return (
|
||||
await canonicalSkillFiles(
|
||||
home,
|
||||
clientIds,
|
||||
environment,
|
||||
platform,
|
||||
resources,
|
||||
)
|
||||
).some(({ content, original }) => original?.content !== content);
|
||||
return await canonicalSkillNeedsInstallByReading(
|
||||
home,
|
||||
clientIds,
|
||||
environment,
|
||||
platform,
|
||||
resources,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause instanceof RegularFileCleanupFailure ||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export interface SetupClient {
|
|||
| "grok"
|
||||
| "omp"
|
||||
| "pi"
|
||||
| "kimi"
|
||||
| "hermes"
|
||||
| "zcode"
|
||||
| "dsh"
|
||||
|
|
@ -165,6 +166,10 @@ const claudeCodeMarkerDirectory = ({ home, env }: ClientPathContext): string =>
|
|||
const codexDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.CODEX_HOME || join(home, ".codex");
|
||||
|
||||
/** Kimi Code stores MCP declarations beside the rest of its home data. */
|
||||
const kimiDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.KIMI_CODE_HOME || join(home, ".kimi-code");
|
||||
|
||||
const qoderDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.QODER_CONFIG_DIR || join(home, ".qoder");
|
||||
|
||||
|
|
@ -534,7 +539,7 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
|
|||
configPath: ({ home, platform, env }: ClientPathContext) =>
|
||||
join(devinDirectory({ home, platform, env }), "mcp_config.json"),
|
||||
markerPath: devinDirectory,
|
||||
format: "json",
|
||||
format: "devin",
|
||||
},
|
||||
{
|
||||
name: "opencode",
|
||||
|
|
@ -576,6 +581,14 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
|
|||
markerPath: qwenCodeDirectory,
|
||||
format: "json",
|
||||
},
|
||||
{
|
||||
name: "kimi",
|
||||
displayName: "Kimi Code",
|
||||
configPath: (context: ClientPathContext) =>
|
||||
join(kimiDirectory(context), "mcp.json"),
|
||||
markerPath: kimiDirectory,
|
||||
format: "kimi",
|
||||
},
|
||||
{
|
||||
name: "vscode",
|
||||
displayName: "VS Code",
|
||||
|
|
|
|||
|
|
@ -627,11 +627,16 @@ const removeManagedSkills = async (
|
|||
platform: NodeJS.Platform,
|
||||
writeBlocker?: (path: string) => string | undefined,
|
||||
): Promise<UninstallItem> => {
|
||||
// Every destination shares one summary item, so each result is labeled by
|
||||
// the directory it refers to.
|
||||
// Every destination shares one summary item; preserve both client and path.
|
||||
const results = await Promise.all(
|
||||
skillDestinations(home, undefined, environment, platform).map(({ path }) =>
|
||||
removeManagedSkill(path, path, fileSystem, writeBlocker),
|
||||
skillDestinations(home, undefined, environment, platform).map(
|
||||
({ client, path }) =>
|
||||
removeManagedSkill(
|
||||
path,
|
||||
`${client} (${path})`,
|
||||
fileSystem,
|
||||
writeBlocker,
|
||||
),
|
||||
),
|
||||
);
|
||||
const failed = results.find(({ status }) => status === "failed");
|
||||
|
|
|
|||
|
|
@ -83,3 +83,37 @@ it("preserves cancellation before execution", async () => {
|
|||
if (result.ok) return;
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
});
|
||||
|
||||
it("preserves a cancellation that races a completed observation", async () => {
|
||||
const controller = new AbortController();
|
||||
const racingPort: ApktoolResourceAnalysisPort = {
|
||||
async inspectAvailability() {
|
||||
return { status: "available", code: null, reason: null, diagnostics: {} };
|
||||
},
|
||||
async close() {},
|
||||
async execute() {
|
||||
// The provider may finish its work before the abort is delivered, so a
|
||||
// successful result is not by itself proof the caller still wants it.
|
||||
controller.abort();
|
||||
return ok(execution);
|
||||
},
|
||||
};
|
||||
const result = await new ApktoolResourceAnalysisService(racingPort).execute(
|
||||
"decode_android_resources",
|
||||
{ path: "/targets/app.apk", include_strings: true },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
// The observation the provider completed is retained on the cancellation, so
|
||||
// a session still records the Evidence instead of losing finished work.
|
||||
const retained = parseEvidence(result.error.partialObservation);
|
||||
expect(retained.operation).toBe("decode_android_resources");
|
||||
expect(retained.provider).toEqual(identity);
|
||||
expect(retained.parameters).toEqual({
|
||||
path: "/targets/app.apk",
|
||||
include_strings: true,
|
||||
});
|
||||
expect(retained.confidence).toBe("observed");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -283,7 +283,9 @@ export class BinarySession
|
|||
previous.route.identity.id,
|
||||
);
|
||||
if (!closed.ok) {
|
||||
this.clearSessionRecords();
|
||||
// A failed open retires the provider cache, not the investigation.
|
||||
// Preserve Evidence and Unknown history for export and recovery.
|
||||
this.clearSnapshot();
|
||||
this.#clearRuntimeAvailability();
|
||||
return closed;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,29 @@ it("labels an envelope containing debugger-derived maps as derived", async () =>
|
|||
expect(evidence.raw_result).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["x86_64-little-endian", "x86_64"],
|
||||
["aarch64-little-endian", "arm64"],
|
||||
] as const)(
|
||||
"names the CPU family carried by a %s core on created Evidence",
|
||||
async (architecture, expected) => {
|
||||
const fixture = recordedCrashFixture("/selected.core", architecture);
|
||||
const service = new RecordedCrashService({
|
||||
inspectAndClose(input, options) {
|
||||
return this.inspect(input, options);
|
||||
},
|
||||
identity: RECORDED_CRASH_TEST_PROVIDER,
|
||||
inspect: () => Promise.resolve(ok(fixture)),
|
||||
});
|
||||
const result = await service.inspect({
|
||||
path: fixture.artifact.path,
|
||||
include_debugger_context: false,
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(parseEvidence(result.value).subject?.architecture).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ path: "relative.elf" },
|
||||
{ path: "/selected.core", approval: true },
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
inspectRecordedCrashInputSchema,
|
||||
RECORDED_LINUX_CORE_PROFILES,
|
||||
recordedCrashSchema,
|
||||
} from "../../domain/native/recordedCrash.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
|
|
@ -101,7 +102,9 @@ export class RecordedCrashService {
|
|||
{
|
||||
path: value.artifact.path,
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
architecture:
|
||||
RECORDED_LINUX_CORE_PROFILES[value.architecture]
|
||||
.evidenceArchitecture,
|
||||
sha256: value.artifact.sha256,
|
||||
},
|
||||
this.provider.identity,
|
||||
|
|
|
|||
|
|
@ -100,4 +100,10 @@ const parameters = (
|
|||
...(input.source_paths === undefined
|
||||
? {}
|
||||
: { source_paths: input.source_paths }),
|
||||
...(input.max_heap_mb === undefined
|
||||
? {}
|
||||
: { max_heap_mb: input.max_heap_mb }),
|
||||
...(input.analysis_timeout_ms === undefined
|
||||
? {}
|
||||
: { analysis_timeout_ms: input.analysis_timeout_ms }),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -62,26 +62,7 @@ export const analyzeJavaScriptApplicationValidated = async (
|
|||
});
|
||||
await checkpointJavaScriptAnalysis(options.signal);
|
||||
reconstructed = await reconstructJavaScriptArtifact(
|
||||
{
|
||||
input_path: input.input_path,
|
||||
format: input.format,
|
||||
integrity_policy: input.integrity_policy,
|
||||
...(input.max_semantic_nodes === undefined
|
||||
? {}
|
||||
: { max_semantic_nodes: input.max_semantic_nodes }),
|
||||
...(input.max_semantic_nodes_per_file === undefined
|
||||
? {}
|
||||
: { max_semantic_nodes_per_file: input.max_semantic_nodes_per_file }),
|
||||
...(input.source_paths === undefined
|
||||
? {}
|
||||
: { source_paths: input.source_paths }),
|
||||
...(input.max_heap_mb === undefined
|
||||
? {}
|
||||
: { max_heap_mb: input.max_heap_mb }),
|
||||
...(input.analysis_timeout_ms === undefined
|
||||
? {}
|
||||
: { analysis_timeout_ms: input.analysis_timeout_ms }),
|
||||
},
|
||||
input,
|
||||
scope,
|
||||
options.signal,
|
||||
options.progress,
|
||||
|
|
|
|||
|
|
@ -457,9 +457,8 @@ const bareModuleCandidate = (
|
|||
dependencyDirectory = posix.dirname(confined);
|
||||
}
|
||||
const source = input.files.get(input.sourcePath);
|
||||
let directory = posix.dirname(input.sourcePath);
|
||||
let nearestNotFound: CandidateResolution | null = null;
|
||||
while (true) {
|
||||
for (const directory of packageLookupDirectories(input)) {
|
||||
const candidate = posix.join(directory, dependencyDirectory);
|
||||
const metadata = readPackageMetadata(input, candidate);
|
||||
const continueCommonJsLookup =
|
||||
|
|
@ -487,8 +486,6 @@ const bareModuleCandidate = (
|
|||
nearestNotFound ??= packageResolution;
|
||||
} else return packageResolution;
|
||||
}
|
||||
if (directory === "." || directory === "") break;
|
||||
directory = posix.dirname(directory);
|
||||
}
|
||||
if (nearestNotFound !== null) return nearestNotFound;
|
||||
return unresolvedCandidate("external", [
|
||||
|
|
@ -496,6 +493,21 @@ const bareModuleCandidate = (
|
|||
]);
|
||||
};
|
||||
|
||||
function* packageLookupDirectories(
|
||||
input: ResolveArtifactPathInput,
|
||||
): Generator<string> {
|
||||
let directory = posix.dirname(input.sourcePath);
|
||||
while (true) {
|
||||
if (
|
||||
input.moduleKind !== "require" ||
|
||||
posix.basename(directory) !== "node_modules"
|
||||
)
|
||||
yield directory;
|
||||
if (directory === "." || directory === "") return;
|
||||
directory = posix.dirname(directory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nearest same-container package.json, stopping at a `node_modules` directory
|
||||
* so a nested package cannot inherit an ancestor's map.
|
||||
|
|
@ -960,15 +972,29 @@ const resolveCandidate = (
|
|||
candidate: string,
|
||||
packageChain: ReadonlySet<string>,
|
||||
emittedSourceCandidates: readonly TypeScriptEmittedSourceCandidate[] = [],
|
||||
): CandidateResolution =>
|
||||
resolveFileCandidates(input, fileCandidates(candidate, input.moduleKind)) ??
|
||||
resolveRuntimeSourceCandidates(input, candidate, emittedSourceCandidates) ??
|
||||
resolveDirectory(
|
||||
): CandidateResolution => {
|
||||
if (!commonJsDirectoryRequest(input)) {
|
||||
const file =
|
||||
resolveFileCandidates(
|
||||
input,
|
||||
fileCandidates(candidate, input.moduleKind),
|
||||
) ??
|
||||
resolveRuntimeSourceCandidates(input, candidate, emittedSourceCandidates);
|
||||
if (file !== null) return file;
|
||||
}
|
||||
return resolveDirectory(
|
||||
input,
|
||||
candidate,
|
||||
readPackageMetadata(input, candidate),
|
||||
packageChain,
|
||||
);
|
||||
};
|
||||
|
||||
const commonJsDirectoryRequest = (input: ResolveArtifactPathInput): boolean =>
|
||||
input.context === "module-specifier" &&
|
||||
input.moduleKind === "require" &&
|
||||
(input.declaredPath.endsWith("/") ||
|
||||
/(?:^|\/)\.{1,2}$/u.test(input.declaredPath));
|
||||
|
||||
const resolveDirectory = (
|
||||
input: ResolveArtifactPathInput,
|
||||
|
|
|
|||
|
|
@ -277,15 +277,9 @@ export const reconstructJavaScriptArtifact = async (
|
|||
limitations: completed.graph.limitations,
|
||||
}),
|
||||
);
|
||||
const { max_heap_mb, analysis_timeout_ms, ...coreInput } = input;
|
||||
const parameters = {
|
||||
...coreInput,
|
||||
...(max_heap_mb === undefined ? {} : { max_heap_mb }),
|
||||
...(analysis_timeout_ms === undefined ? {} : { analysis_timeout_ms }),
|
||||
};
|
||||
const evidence =
|
||||
await createOwnedJavaScriptApplicationEvidenceCooperatively(
|
||||
parameters,
|
||||
input,
|
||||
partial,
|
||||
);
|
||||
throw interruption.retainPartialObservation(evidence);
|
||||
|
|
|
|||
|
|
@ -199,6 +199,49 @@ const testAnalysis = (
|
|||
});
|
||||
|
||||
describe("native UI action trace", () => {
|
||||
it("retains Hopper targetless call evidence alongside fallback callee routes", async () => {
|
||||
const result = await new EnhancedTools(
|
||||
testAnalysis({
|
||||
procedure_address: () => execution("procedure_address", "0x1000"),
|
||||
procedure_references: (parameters) =>
|
||||
ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
reference_kinds_available: false,
|
||||
references: [],
|
||||
unresolved_calls:
|
||||
parameters.procedure === "0x1000"
|
||||
? [{ address: "0x1008", reason: "No static target" }]
|
||||
: [],
|
||||
},
|
||||
{ id: "hopper", name: "Hopper", version: "fixture" },
|
||||
),
|
||||
),
|
||||
}),
|
||||
).execute("trace_native_ui_action", { action: "0x1000" });
|
||||
if (!result.ok) throw result.error;
|
||||
const trace = nativeInvestigationTraceSchema.parse(result.value);
|
||||
expect(trace.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation: "provider_call",
|
||||
to: "native:function:0x2000",
|
||||
}),
|
||||
);
|
||||
expect(trace.unresolved).toContainEqual(
|
||||
expect.objectContaining({
|
||||
from: "native:function:0x1000",
|
||||
to: null,
|
||||
reason: "No static target",
|
||||
evidence: [
|
||||
expect.objectContaining({
|
||||
description: "Hopper reported an unresolved call: No static target",
|
||||
location: { address: "0x1008", file_offset: null },
|
||||
artifact_sha256: target,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
it("accepts an explicit native seed without an authored UI connection", async () => {
|
||||
const result = await new EnhancedTools(
|
||||
testAnalysis({
|
||||
|
|
|
|||
|
|
@ -7,6 +7,59 @@ import { err, ok } from "../../domain/result.js";
|
|||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
describe("untyped native call route fallback", () => {
|
||||
it.each([{ callees: [] }, { callees: ["0x2000"] }])(
|
||||
"preserves targetless calls and their provider when fallback callees are $callees",
|
||||
async ({ callees }) => {
|
||||
const calls: string[] = [];
|
||||
const referenceProvider = { ...provider, id: "hopper", name: "Hopper" };
|
||||
const fallbackProvider = { ...provider, id: "fallback" };
|
||||
const signal = new AbortController().signal;
|
||||
const unknown = { address: "0x1008", reason: "No static target" };
|
||||
const result = await readNativeCallRoutes(
|
||||
{
|
||||
execute: async (operation, parameters, options) => {
|
||||
calls.push(operation);
|
||||
expect(parameters.procedure).toBe("0x1000");
|
||||
expect(options).toEqual({ signal, selectorOrigin: "provider" });
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
operation === "procedure_references"
|
||||
? {
|
||||
reference_kinds_available: false,
|
||||
references: [],
|
||||
unresolved_calls: [unknown],
|
||||
}
|
||||
: callees,
|
||||
operation === "procedure_references"
|
||||
? referenceProvider
|
||||
: fallbackProvider,
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
"0x1000",
|
||||
signal,
|
||||
"provider",
|
||||
);
|
||||
expect(calls).toEqual(["procedure_references", "procedure_callees"]);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
provider: fallbackProvider,
|
||||
routes: callees.map((address) => ({
|
||||
address,
|
||||
site: "0x1000",
|
||||
relation: "provider_call",
|
||||
resolution: "inferred",
|
||||
})),
|
||||
unknowns: [{ ...unknown, provider: referenceProvider }],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("typed native call routes", () => {
|
||||
it("preserves computed targets, ambiguity and targetless call sites", async () => {
|
||||
const reference = jsonValueSchema.parse(ghidraReferenceEdge());
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { z } from "zod";
|
|||
import type {
|
||||
AnalysisOperationPort,
|
||||
ExecutionOptions,
|
||||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
import {
|
||||
parseRelatedAddresses,
|
||||
referenceKindSchema,
|
||||
} from "../../domain/hopperValues.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
|
||||
const referencesSchema = z.object({
|
||||
|
|
@ -40,15 +40,29 @@ export const readNativeCallRoutes = async (
|
|||
{ procedure, direction: "outgoing" },
|
||||
options,
|
||||
);
|
||||
let unknowns: Array<{
|
||||
address: string;
|
||||
reason: string;
|
||||
provider: ProviderIdentity;
|
||||
}> = [];
|
||||
if (execution.ok) {
|
||||
const parsed = referencesSchema.safeParse(execution.value.result);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
return {
|
||||
ok: false as const,
|
||||
error: new AnalysisOutputError(
|
||||
"procedure_references",
|
||||
"Invalid typed call references",
|
||||
),
|
||||
);
|
||||
unknowns,
|
||||
};
|
||||
// Missing detailed reference flags do not invalidate observed targetless
|
||||
// calls. Keep their producing provider even if another provider supplies
|
||||
// the fallback callee inventory.
|
||||
unknowns = parsed.data.unresolved_calls.map((call) => ({
|
||||
...call,
|
||||
provider: execution.value.provider,
|
||||
}));
|
||||
const references = parsed.data.references;
|
||||
if (
|
||||
parsed.data.reference_kinds_available === true ||
|
||||
|
|
@ -64,45 +78,52 @@ export const readNativeCallRoutes = async (
|
|||
targets.add(call.target_address);
|
||||
countBySite.set(call.source_address, targets);
|
||||
}
|
||||
return ok({
|
||||
provider: execution.value.provider,
|
||||
routes: calls.map((call) => ({
|
||||
address: call.target_procedure?.address ?? call.target_address,
|
||||
site: call.source_address,
|
||||
relation:
|
||||
call.kind.available && (call.kind.computed || call.kind.indirect)
|
||||
? ("indirect_call" as const)
|
||||
: ("direct_call" as const),
|
||||
resolution:
|
||||
(countBySite.get(call.source_address)?.size ?? 0) > 1
|
||||
? ("ambiguous" as const)
|
||||
: call.kind.available &&
|
||||
(call.kind.computed || call.kind.indirect)
|
||||
? ("resolved" as const)
|
||||
: ("observed" as const),
|
||||
})),
|
||||
unknowns: parsed.data.unresolved_calls,
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
value: {
|
||||
provider: execution.value.provider,
|
||||
routes: calls.map((call) => ({
|
||||
address: call.target_procedure?.address ?? call.target_address,
|
||||
site: call.source_address,
|
||||
relation:
|
||||
call.kind.available && (call.kind.computed || call.kind.indirect)
|
||||
? ("indirect_call" as const)
|
||||
: ("direct_call" as const),
|
||||
resolution:
|
||||
(countBySite.get(call.source_address)?.size ?? 0) > 1
|
||||
? ("ambiguous" as const)
|
||||
: call.kind.available &&
|
||||
(call.kind.computed || call.kind.indirect)
|
||||
? ("resolved" as const)
|
||||
: ("observed" as const),
|
||||
})),
|
||||
unknowns,
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (execution.error._tag !== "AnalysisCapabilityUnavailableError")
|
||||
return err(execution.error);
|
||||
return { ok: false as const, error: execution.error, unknowns };
|
||||
const callees = await analysis.execute(
|
||||
"procedure_callees",
|
||||
{ procedure },
|
||||
options,
|
||||
);
|
||||
if (!callees.ok) return callees;
|
||||
if (!callees.ok)
|
||||
return { ok: false as const, error: callees.error, unknowns };
|
||||
const parsed = parseRelatedAddresses(callees.value.result, "callees");
|
||||
return parsed.ok
|
||||
? ok({
|
||||
provider: callees.value.provider,
|
||||
routes: parsed.value.map((address) => ({
|
||||
address,
|
||||
site: procedure,
|
||||
relation: "provider_call" as const,
|
||||
resolution: "inferred" as const,
|
||||
})),
|
||||
unknowns: [],
|
||||
})
|
||||
: parsed;
|
||||
? {
|
||||
ok: true as const,
|
||||
value: {
|
||||
provider: callees.value.provider,
|
||||
routes: parsed.value.map((address) => ({
|
||||
address,
|
||||
site: procedure,
|
||||
relation: "provider_call" as const,
|
||||
resolution: "inferred" as const,
|
||||
})),
|
||||
unknowns,
|
||||
},
|
||||
}
|
||||
: { ok: false as const, error: parsed.error, unknowns };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -414,9 +414,44 @@ const addDirectCallees = async (input: {
|
|||
"provider",
|
||||
);
|
||||
examined += 1;
|
||||
if (!execution.ok && execution.error._tag === "AnalysisCancelledError")
|
||||
return { error: execution.error };
|
||||
const unknowns = execution.ok
|
||||
? execution.value.unknowns
|
||||
: execution.unknowns;
|
||||
for (const [index, unknown] of unknowns.entries()) {
|
||||
if (edges.size >= input.input.max_edges) {
|
||||
truncated = true;
|
||||
omitted += unknowns.length - index;
|
||||
break;
|
||||
}
|
||||
const id = `unknown-dispatch:${current.address}:${unknown.address}`;
|
||||
edges.set(id, {
|
||||
id,
|
||||
from: current.node.id,
|
||||
to: null,
|
||||
relation: "indirect_call",
|
||||
resolution: "unresolved",
|
||||
reason: unknown.reason,
|
||||
evidence: [
|
||||
{
|
||||
kind: "reference",
|
||||
provider: unknown.provider,
|
||||
description: `${unknown.provider.name} reported an unresolved call: ${unknown.reason}`,
|
||||
location: { address: unknown.address, file_offset: null },
|
||||
artifact_path: null,
|
||||
artifact_sha256: input.targetSha256,
|
||||
},
|
||||
],
|
||||
limitations: [],
|
||||
});
|
||||
}
|
||||
if (!execution.ok) {
|
||||
if (execution.error._tag === "AnalysisCancelledError")
|
||||
return { error: execution.error };
|
||||
if (edges.size >= input.input.max_edges) {
|
||||
truncated = true;
|
||||
omitted += 1;
|
||||
continue;
|
||||
}
|
||||
const id = `unknown-callees:${current.address}`;
|
||||
edges.set(id, {
|
||||
id,
|
||||
|
|
@ -432,32 +467,6 @@ const addDirectCallees = async (input: {
|
|||
});
|
||||
continue;
|
||||
}
|
||||
for (const unknown of execution.value.unknowns) {
|
||||
if (edges.size >= input.input.max_edges) {
|
||||
truncated = true;
|
||||
omitted++;
|
||||
break;
|
||||
}
|
||||
const id = `unknown-dispatch:${current.address}:${unknown.address}`;
|
||||
edges.set(id, {
|
||||
id,
|
||||
from: current.node.id,
|
||||
to: null,
|
||||
relation: "indirect_call",
|
||||
resolution: "unresolved",
|
||||
reason: unknown.reason,
|
||||
evidence: [
|
||||
{
|
||||
kind: "reference",
|
||||
description: unknown.reason,
|
||||
location: { address: unknown.address, file_offset: null },
|
||||
artifact_path: null,
|
||||
artifact_sha256: input.targetSha256,
|
||||
},
|
||||
],
|
||||
limitations: [],
|
||||
});
|
||||
}
|
||||
for (const [index, route] of execution.value.routes.entries()) {
|
||||
const address = route.address;
|
||||
if (edges.size >= input.input.max_edges) {
|
||||
|
|
@ -469,6 +478,7 @@ const addDirectCallees = async (input: {
|
|||
const evidence = [
|
||||
{
|
||||
kind: "reference" as const,
|
||||
provider: execution.value.provider,
|
||||
description: `${execution.value.provider.name} reported ${route.relation} from ${current.address} to ${address}`,
|
||||
location: { address: route.site, file_offset: null },
|
||||
artifact_path: null,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,198 @@
|
|||
import { expect, it } from "vitest";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import { wasmInterfaceComparisonSchema } from "../../contracts/wasm/wasmComparisonContracts.js";
|
||||
import { compareWasmInterfaces } from "./WasmInterfaceComparisonService.js";
|
||||
import type { WasmInterface } from "../../domain/wasm/wasmInterface.js";
|
||||
import { wasmArtifactFixture } from "../../../tests/fixtures/wasm/artifact.js";
|
||||
|
||||
const facts = (
|
||||
name: string,
|
||||
parameter: "i32" | "i64" = "i32",
|
||||
): WasmInterface => ({
|
||||
state: "complete",
|
||||
issues: [],
|
||||
entries: [
|
||||
{
|
||||
direction: "export",
|
||||
module: null,
|
||||
name,
|
||||
kind: "func",
|
||||
index: 0,
|
||||
signature: { kind: "func", params: [parameter], results: [] },
|
||||
wat_form: `(export "${name}" (func 0))`,
|
||||
issue: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const evidence = (interfaceFacts?: WasmInterface) => {
|
||||
const report = wasmArtifactFixture();
|
||||
if (interfaceFacts !== undefined) report.interface = interfaceFacts;
|
||||
return createEvidence(
|
||||
{
|
||||
path: report.artifact.path,
|
||||
sha256: report.artifact.sha256,
|
||||
format: "file",
|
||||
},
|
||||
{ id: "fixture", name: "typed fixture", version: "1" },
|
||||
{
|
||||
operation: "inspect_wasm_artifact",
|
||||
parameters: { path: report.artifact.path },
|
||||
result: report,
|
||||
confidence: "observed",
|
||||
},
|
||||
);
|
||||
};
|
||||
const compare = async (
|
||||
left: ReturnType<typeof evidence>,
|
||||
right: ReturnType<typeof evidence>,
|
||||
) => {
|
||||
const result = await compareWasmInterfaces({ left, right, scope: "both" });
|
||||
if (!result.ok) throw result.error;
|
||||
return wasmInterfaceComparisonSchema.parse(result.value.normalized_result);
|
||||
};
|
||||
|
||||
it("reports all five comparison states, including duplicate ambiguity and explicit selected scope", async () => {
|
||||
const left = facts("changed");
|
||||
const right = facts("changed", "i64");
|
||||
left.entries.push(
|
||||
...facts("unchanged").entries,
|
||||
...facts("removed").entries,
|
||||
...facts("dup").entries,
|
||||
...facts("dup").entries,
|
||||
);
|
||||
right.entries.push(
|
||||
...facts("unchanged").entries,
|
||||
...facts("added").entries,
|
||||
...facts("dup").entries,
|
||||
);
|
||||
const report = await compare(evidence(left), evidence(right));
|
||||
expect(report.summary).toEqual({
|
||||
changed: 1,
|
||||
unchanged: 1,
|
||||
removed: 1,
|
||||
added: 1,
|
||||
unknown: 1,
|
||||
});
|
||||
expect(report.changes.find((c) => c.name === "dup")).toMatchObject({
|
||||
state: "unknown",
|
||||
left: [expect.anything(), expect.anything()],
|
||||
right: [expect.anything()],
|
||||
});
|
||||
const selected = await compareWasmInterfaces({
|
||||
left: evidence(left),
|
||||
right: evidence(right),
|
||||
scope: "imports",
|
||||
});
|
||||
if (!selected.ok) throw selected.error;
|
||||
expect(selected.value.normalized_result).toMatchObject({
|
||||
changes: [],
|
||||
coverage: "complete-within-inputs",
|
||||
});
|
||||
});
|
||||
|
||||
it("withholds absence claims for historical or partial inventories and unsupported signatures", async () => {
|
||||
const known = facts("f");
|
||||
const absent = await compare(evidence(), evidence(known));
|
||||
expect(absent).toMatchObject({
|
||||
coverage: "partial",
|
||||
changes: [{ state: "unknown" }],
|
||||
});
|
||||
const partial: WasmInterface = {
|
||||
state: "partial",
|
||||
issues: ["unread import table"],
|
||||
entries: [],
|
||||
};
|
||||
expect(await compare(evidence(partial), evidence(known))).toMatchObject({
|
||||
changes: [{ state: "unknown" }],
|
||||
});
|
||||
const unsupported = facts("f");
|
||||
unsupported.entries[0]!.signature = null;
|
||||
expect(await compare(evidence(known), evidence(unsupported))).toMatchObject({
|
||||
changes: [{ state: "unknown" }],
|
||||
});
|
||||
const partialMatch = facts("f");
|
||||
partialMatch.state = "partial";
|
||||
partialMatch.issues = ["Later imports were not examined"];
|
||||
expect(await compare(evidence(partialMatch), evidence(known))).toMatchObject({
|
||||
changes: [{ state: "unknown" }],
|
||||
});
|
||||
expect(await compare(evidence(), evidence())).toMatchObject({
|
||||
coverage: "unknown",
|
||||
changes: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("authenticates semantic Evidence and binds display subject and selection identity", async () => {
|
||||
const left = JSON.parse(JSON.stringify(evidence(facts("f"))));
|
||||
left.normalized_result.interface.entries[0].name = "tampered";
|
||||
expect(
|
||||
await compareWasmInterfaces({
|
||||
left,
|
||||
right: evidence(facts("f")),
|
||||
scope: "both",
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
|
||||
const rebound = JSON.parse(JSON.stringify(evidence(facts("f"))));
|
||||
rebound.subject.local_path = "/other.wasm";
|
||||
expect(
|
||||
await compareWasmInterfaces({
|
||||
left: rebound,
|
||||
right: evidence(facts("f")),
|
||||
scope: "both",
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
|
||||
});
|
||||
|
||||
it("resolves exact retained references and permits cancellation between comparison batches", async () => {
|
||||
const left = evidence(facts("f"));
|
||||
const right = evidence(facts("f", "i64"));
|
||||
const references = {
|
||||
left: { kind: "retained-evidence", evidence_id: left.evidence_id },
|
||||
right: { kind: "retained-evidence", evidence_id: right.evidence_id },
|
||||
scope: "both",
|
||||
};
|
||||
expect(await compareWasmInterfaces(references)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "EvidenceIntegrityError" },
|
||||
});
|
||||
const result = await compareWasmInterfaces(references, {}, (id) =>
|
||||
id === left.evidence_id ? left : right,
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.evidence_links).toEqual([
|
||||
left.evidence_id,
|
||||
right.evidence_id,
|
||||
]);
|
||||
const large = facts("f");
|
||||
large.entries.push(
|
||||
...Array.from({ length: 2000 }, (_, i) => facts(`f${i}`).entries[0]!),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const pending = compareWasmInterfaces(
|
||||
{ left: evidence(large), right: evidence(large), scope: "both" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
setImmediate(() => controller.abort());
|
||||
expect(await pending).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"keeps a one-sided unsupported interface unknown with reverse=%s",
|
||||
async (reverse) => {
|
||||
const empty: WasmInterface = { state: "complete", issues: [], entries: [] };
|
||||
const unsupported = facts("exception");
|
||||
unsupported.entries[0]!.signature = null;
|
||||
unsupported.entries[0]!.issue = "Unsupported exnref global type";
|
||||
const sides = reverse ? [unsupported, empty] : [empty, unsupported];
|
||||
expect(await compare(evidence(sides[0]), evidence(sides[1]))).toMatchObject(
|
||||
{
|
||||
coverage: "partial",
|
||||
summary: { added: 0, removed: 0, unknown: 1 },
|
||||
changes: [{ state: "unknown" }],
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
import {
|
||||
WASM_INTERFACE_REPRESENTATION_BYTES,
|
||||
wasmInterfaceEntryRepresentationBytes,
|
||||
} from "../../domain/wasm/wasmInterface.js";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import {
|
||||
compareWasmInterfacesInputSchema,
|
||||
wasmInterfaceComparisonSchema,
|
||||
} from "../../contracts/wasm/wasmComparisonContracts.js";
|
||||
import {
|
||||
createEvidence,
|
||||
parseEvidence,
|
||||
type Evidence,
|
||||
} from "../../domain/evidence.js";
|
||||
import {
|
||||
wasmArtifactSchema,
|
||||
type WasmArtifact,
|
||||
} from "../../domain/wasm/wasmArtifact.js";
|
||||
import type { WasmInterfaceEntry } from "../../domain/wasm/wasmInterface.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { ARTIFACT_COMPARISON_PROVIDER } from "../InvestigationProviders.js";
|
||||
import {
|
||||
resolvePairedEvidenceRequest,
|
||||
type EvidenceLookup,
|
||||
} from "../EvidenceInputResolver.js";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { z } from "zod";
|
||||
|
||||
const OPERATION = "compare_wasm_interfaces";
|
||||
const parseInspection = (
|
||||
raw: unknown,
|
||||
): { evidence: Evidence; result: WasmArtifact } => {
|
||||
const evidence = parseEvidence(raw);
|
||||
if (evidence.operation !== "inspect_wasm_artifact")
|
||||
throw new TypeError("Expected complete inspect_wasm_artifact Evidence");
|
||||
const result = wasmArtifactSchema.parse(evidence.normalized_result);
|
||||
if (
|
||||
evidence.subject?.digest.sha256 !== result.artifact.sha256 ||
|
||||
evidence.subject.local_path !== result.artifact.path ||
|
||||
evidence.parameters.path !== result.artifact.path
|
||||
)
|
||||
throw new TypeError(
|
||||
"WASM Evidence subject/selection does not match normalized artifact identity",
|
||||
);
|
||||
return { evidence, result };
|
||||
};
|
||||
|
||||
/** Compare only producer-owned typed facts; no raw WAT or original file is reread. */
|
||||
export const compareWasmInterfaces = async (
|
||||
rawInput: unknown,
|
||||
options?: ExecutionOptions,
|
||||
lookup?: EvidenceLookup,
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const parsed = compareWasmInterfacesInputSchema.safeParse(rawInput);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(OPERATION, parsed.error.issues, rawInput),
|
||||
);
|
||||
const resolved = resolvePairedEvidenceRequest(parsed.data, lookup);
|
||||
if (!resolved.ok) return resolved;
|
||||
try {
|
||||
await setImmediate();
|
||||
options?.signal?.throwIfAborted();
|
||||
const left = parseInspection(resolved.value.left);
|
||||
const right = parseInspection(resolved.value.right);
|
||||
const scope = parsed.data.scope;
|
||||
const selected = (entry: WasmInterfaceEntry): boolean =>
|
||||
scope === "both" ||
|
||||
entry.direction === (scope === "imports" ? "import" : "export");
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
left: WasmInterfaceEntry[];
|
||||
right: WasmInterfaceEntry[];
|
||||
entry: WasmInterfaceEntry;
|
||||
}
|
||||
>();
|
||||
let work = 0;
|
||||
for (const [side, inspection] of [
|
||||
["left", left],
|
||||
["right", right],
|
||||
] as const) {
|
||||
for (const entry of inspection.result.interface?.entries ?? []) {
|
||||
if (++work % 256 === 0) {
|
||||
await setImmediate();
|
||||
options?.signal?.throwIfAborted();
|
||||
}
|
||||
if (!selected(entry)) continue;
|
||||
const key = JSON.stringify([entry.direction, entry.module, entry.name]);
|
||||
let group = groups.get(key);
|
||||
if (group === undefined) {
|
||||
group = { left: [], right: [], entry };
|
||||
groups.set(key, group);
|
||||
}
|
||||
group[side].push(entry);
|
||||
}
|
||||
}
|
||||
const knownInventory = (inspection: typeof left): boolean =>
|
||||
inspection.result.interface?.state === "complete";
|
||||
const changes: z.infer<typeof wasmInterfaceComparisonSchema>["changes"] =
|
||||
[];
|
||||
const summary = {
|
||||
added: 0,
|
||||
removed: 0,
|
||||
changed: 0,
|
||||
unchanged: 0,
|
||||
unknown: 0,
|
||||
};
|
||||
let remaining = WASM_INTERFACE_REPRESENTATION_BYTES;
|
||||
for (const group of groups.values()) {
|
||||
remaining -=
|
||||
512 +
|
||||
group.left.reduce(
|
||||
(sum, entry) => sum + wasmInterfaceEntryRepresentationBytes(entry),
|
||||
0,
|
||||
) +
|
||||
group.right.reduce(
|
||||
(sum, entry) => sum + wasmInterfaceEntryRepresentationBytes(entry),
|
||||
0,
|
||||
);
|
||||
if (remaining < 0)
|
||||
throw new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
"capacity",
|
||||
"WASM comparison exceeds typed interface representation capacity",
|
||||
{ maximum_bytes: WASM_INTERFACE_REPRESENTATION_BYTES },
|
||||
);
|
||||
if (++work % 256 === 0) {
|
||||
await setImmediate();
|
||||
options?.signal?.throwIfAborted();
|
||||
}
|
||||
let state: keyof typeof summary;
|
||||
let reason: string | null = null;
|
||||
const l = group.left[0];
|
||||
const r = group.right[0];
|
||||
if (group.left.length > 1 || group.right.length > 1) {
|
||||
state = "unknown";
|
||||
reason =
|
||||
"Duplicate interface names are ambiguous; all occurrences retained";
|
||||
} else if (!knownInventory(left) || !knownInventory(right)) {
|
||||
state = "unknown";
|
||||
reason =
|
||||
"Incomplete interface inventories cannot establish per-key uniqueness or absence";
|
||||
} else if (
|
||||
[l, r].some(
|
||||
(entry) =>
|
||||
entry !== undefined &&
|
||||
(entry.signature === null ||
|
||||
entry.issue !== null ||
|
||||
entry.kind === "unknown"),
|
||||
)
|
||||
) {
|
||||
state = "unknown";
|
||||
reason =
|
||||
"Signature/type semantics were not admitted for every present entry";
|
||||
} else if (l === undefined) state = "added";
|
||||
else if (r === undefined) state = "removed";
|
||||
else if (l.kind !== r.kind) state = "changed";
|
||||
else
|
||||
state =
|
||||
JSON.stringify(l.signature) === JSON.stringify(r.signature)
|
||||
? "unchanged"
|
||||
: "changed";
|
||||
summary[state]++;
|
||||
changes.push({
|
||||
direction: group.entry.direction,
|
||||
module: group.entry.module,
|
||||
name: group.entry.name,
|
||||
state,
|
||||
left: group.left,
|
||||
right: group.right,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
const compareText = (a: string, b: string): number =>
|
||||
a < b ? -1 : a > b ? 1 : 0;
|
||||
changes.sort(
|
||||
(a, b) =>
|
||||
compareText(a.direction, b.direction) ||
|
||||
compareText(a.module ?? "", b.module ?? "") ||
|
||||
compareText(a.name, b.name),
|
||||
);
|
||||
const coverage =
|
||||
left.result.interface === undefined &&
|
||||
right.result.interface === undefined
|
||||
? "unknown"
|
||||
: !knownInventory(left) || !knownInventory(right) || summary.unknown > 0
|
||||
? "partial"
|
||||
: "complete-within-inputs";
|
||||
const limitations = [
|
||||
"Exact declared interface equality is not runtime/ABI compatibility, successful linking or original-source identity.",
|
||||
"Typed facts come from the selected validated WABT decoding; unsupported signature semantics and duplicate names remain unknown.",
|
||||
];
|
||||
const report = wasmInterfaceComparisonSchema.parse({
|
||||
left: {
|
||||
evidence_id: left.evidence.evidence_id,
|
||||
artifact: left.result.artifact,
|
||||
},
|
||||
right: {
|
||||
evidence_id: right.evidence.evidence_id,
|
||||
artifact: right.result.artifact,
|
||||
},
|
||||
scope,
|
||||
coverage,
|
||||
changes,
|
||||
summary,
|
||||
limitations,
|
||||
});
|
||||
await setImmediate();
|
||||
options?.signal?.throwIfAborted();
|
||||
return ok(
|
||||
createEvidence(undefined, ARTIFACT_COMPARISON_PROVIDER, {
|
||||
operation: OPERATION,
|
||||
predicateType: "rea.wasm-interface-comparison",
|
||||
parameters: {
|
||||
left_evidence_id: left.evidence.evidence_id,
|
||||
right_evidence_id: right.evidence.evidence_id,
|
||||
scope,
|
||||
},
|
||||
result: report,
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
limitations,
|
||||
evidenceLinks: [left.evidence.evidence_id, right.evidence.evidence_id],
|
||||
}),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, { cause }, [
|
||||
{
|
||||
path: [],
|
||||
reason: "invalid_value",
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { orderAsarListing } from "./AsarArtifactReader.js";
|
||||
|
||||
describe("orderAsarListing", () => {
|
||||
it("orders host-spelled listings by their portable archive path", () => {
|
||||
const posix = orderAsarListing(["/a0", "/a", "/a/b"], "/");
|
||||
const windows = orderAsarListing(["\\a0", "\\a", "\\a\\b"], "\\");
|
||||
|
||||
// "/" (0x2F) precedes "0" (0x30); "\" (0x5C) would follow it.
|
||||
expect(posix.map(({ path }) => path)).toEqual(["a", "a/b", "a0"]);
|
||||
expect(windows.map(({ path }) => path)).toEqual(["a", "a/b", "a0"]);
|
||||
expect(windows.map(({ listed }) => listed)).toEqual([
|
||||
"\\a",
|
||||
"\\a\\b",
|
||||
"\\a0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps POSIX backslashes as literal filename characters", () => {
|
||||
expect(orderAsarListing(["/a\\b"], "/")).toEqual([
|
||||
{ listed: "/a\\b", path: "a\\b" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -13,7 +13,7 @@ import { isAbsolute, join, relative, sep } from "node:path";
|
|||
import { Readable, Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
|
||||
import { listPackage, statFile, uncache } from "@electron/asar";
|
||||
import { getRawHeader, uncache } from "@electron/asar";
|
||||
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
|
|
@ -26,7 +26,11 @@ import { closeAsarHandle, readValidatedAsarEntry } from "./AsarEntryStream.js";
|
|||
import { admitAsarHeader } from "./AsarHeader.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import {
|
||||
readAsarListing,
|
||||
type AsarListedEntry,
|
||||
type AsarMetadata,
|
||||
} from "./AsarListing.js";
|
||||
|
||||
/**
|
||||
* Official Electron ASAR adapter with range-streamed member reads.
|
||||
|
|
@ -51,7 +55,7 @@ export class AsarArtifactReader implements ArtifactReader {
|
|||
) {}
|
||||
|
||||
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
|
||||
let paths: readonly AsarListedPath[];
|
||||
let paths: readonly AsarListedEntry[];
|
||||
try {
|
||||
await this.prepareContainer(undefined, signal);
|
||||
paths = await this.#withSnapshotOperation(async () => {
|
||||
|
|
@ -62,8 +66,9 @@ export class AsarArtifactReader implements ArtifactReader {
|
|||
this.#archiveSize = admitted.archiveBytes;
|
||||
this.#headerSize = admitted.headerBytes;
|
||||
abortIfNeeded(signal);
|
||||
const ordered = orderAsarListing(
|
||||
listPackage(snapshotPath, { isPack: false }),
|
||||
const ordered = await readAsarListing(
|
||||
getRawHeader(snapshotPath).header,
|
||||
signal,
|
||||
);
|
||||
const archiveMetadata = await lstat(snapshotPath);
|
||||
if (!archiveMetadata.isFile() || archiveMetadata.isSymbolicLink())
|
||||
|
|
@ -77,34 +82,25 @@ export class AsarArtifactReader implements ArtifactReader {
|
|||
} catch (cause: unknown) {
|
||||
throw asarFailure(this.path, "inventory", cause);
|
||||
}
|
||||
for (const { listed, path } of paths) {
|
||||
for (const { metadata, path } of paths) {
|
||||
abortIfNeeded(signal);
|
||||
if (path.length === 0) continue;
|
||||
const providerPath = listed.startsWith(sep)
|
||||
? listed.slice(sep.length)
|
||||
: listed;
|
||||
let metadata: ReturnType<typeof statFile>;
|
||||
try {
|
||||
metadata = statFile(this.#snapshotPath(), providerPath, false);
|
||||
} catch (cause: unknown) {
|
||||
throw asarFailure(this.path, `stat ${path}`, cause);
|
||||
}
|
||||
const providerPath = path.split("/").join(sep);
|
||||
const kind =
|
||||
"files" in metadata
|
||||
metadata.files !== undefined
|
||||
? "directory"
|
||||
: "link" in metadata
|
||||
: metadata.link !== undefined
|
||||
? "symlink"
|
||||
: "file";
|
||||
const entry: ArtifactEntry = {
|
||||
path,
|
||||
kind,
|
||||
declaredSize: "size" in metadata ? metadata.size : null,
|
||||
declaredSize: metadata.size ?? null,
|
||||
compressedSize: null,
|
||||
executable: "executable" in metadata && metadata.executable,
|
||||
executable: metadata.executable === true,
|
||||
encrypted: false,
|
||||
byteOffset: null,
|
||||
declaredSha256:
|
||||
"integrity" in metadata &&
|
||||
metadata.integrity !== undefined &&
|
||||
metadata.integrity.algorithm === "SHA256" &&
|
||||
/^[a-f0-9]{64}$/u.test(metadata.integrity.hash)
|
||||
? metadata.integrity.hash
|
||||
|
|
@ -603,7 +599,7 @@ export class AsarArtifactReader implements ArtifactReader {
|
|||
}
|
||||
}
|
||||
|
||||
type AsarFileMetadata = Extract<ReturnType<typeof statFile>, { size: number }>;
|
||||
type AsarFileMetadata = AsarMetadata & { readonly size: number };
|
||||
type AsarEntryState = {
|
||||
readonly metadata: AsarFileMetadata;
|
||||
readonly entry: ArtifactEntry;
|
||||
|
|
@ -652,12 +648,11 @@ const asarUnpackedEntryFailure = (
|
|||
};
|
||||
|
||||
const isAsarFileMetadata = (
|
||||
metadata: ReturnType<typeof statFile>,
|
||||
metadata: AsarMetadata,
|
||||
): metadata is AsarFileMetadata =>
|
||||
"size" in metadata &&
|
||||
typeof metadata.size === "number" &&
|
||||
!("files" in metadata) &&
|
||||
!("link" in metadata);
|
||||
metadata.files === undefined &&
|
||||
metadata.link === undefined;
|
||||
|
||||
const parseArchiveOffset = (value: string): number | undefined => {
|
||||
if (!/^(?:0|[1-9][0-9]*)$/u.test(value)) return undefined;
|
||||
|
|
@ -699,33 +694,6 @@ const waitForOperation = async (
|
|||
});
|
||||
};
|
||||
|
||||
interface AsarListedPath {
|
||||
readonly listed: string;
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair each host-spelled listing with its portable archive path, ordered by
|
||||
* the portable path so Windows and POSIX hosts agree.
|
||||
*
|
||||
* @electron/asar returns paths assembled with the host path module. Keep that
|
||||
* spelling for its API calls, but expose archive paths with portable
|
||||
* separators. On POSIX, backslashes can be literal filename characters and
|
||||
* must remain untouched.
|
||||
*/
|
||||
export const orderAsarListing = (
|
||||
listed: readonly string[],
|
||||
separator: string = sep,
|
||||
): AsarListedPath[] =>
|
||||
listed
|
||||
.map((entry) => ({ listed: entry, path: toArtifactPath(entry, separator) }))
|
||||
.sort((left, right) => compareUnicodeCodePoints(left.path, right.path));
|
||||
|
||||
const toArtifactPath = (listed: string, separator: string): string => {
|
||||
const portable = separator === "\\" ? listed.replaceAll("\\", "/") : listed;
|
||||
return portable.replace(/^\/+|\/+$/gu, "");
|
||||
};
|
||||
|
||||
const asarFailure = (
|
||||
path: string,
|
||||
operation: string,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
import { setImmediate } from "node:timers/promises";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** Capacity for expanded paths and listing records, independent of packed bytes. */
|
||||
const MAX_LISTING_BYTES = 64 * 1024 * 1024;
|
||||
const nodeSchema = z.looseObject({
|
||||
files: z
|
||||
.custom<Record<string, unknown>>(
|
||||
(value) =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value),
|
||||
)
|
||||
.optional(),
|
||||
link: z.string().optional(),
|
||||
size: z.number().nonnegative().safe().optional(),
|
||||
offset: z.string().optional(),
|
||||
unpacked: z.boolean().optional(),
|
||||
executable: z.boolean().optional(),
|
||||
integrity: z
|
||||
.looseObject({ algorithm: z.string(), hash: z.string() })
|
||||
.optional(),
|
||||
});
|
||||
export type AsarMetadata = z.infer<typeof nodeSchema>;
|
||||
export interface AsarListedEntry {
|
||||
readonly path: string;
|
||||
readonly metadata: AsarMetadata;
|
||||
}
|
||||
|
||||
function* ownChildren(files: Record<string, unknown>) {
|
||||
for (const name in files)
|
||||
if (Object.hasOwn(files, name)) yield [name, files[name]] as const;
|
||||
}
|
||||
|
||||
/** Decode each node once; admit expanded paths before concatenation and retain no recursive lookup. */
|
||||
export const readAsarListing = async (
|
||||
header: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AsarListedEntry[]> => {
|
||||
const root = nodeSchema.parse(header);
|
||||
if (root.files === undefined)
|
||||
throw new ArtifactReaderFailure("format", "ASAR root has no files table");
|
||||
const entries: AsarListedEntry[] = [];
|
||||
const pending = [{ parent: "", children: ownChildren(root.files) }];
|
||||
let retainedBytes = 0;
|
||||
let examined = 0;
|
||||
while (pending.length > 0) {
|
||||
if (examined++ % 128 === 0) {
|
||||
await setImmediate();
|
||||
if (signal?.aborted)
|
||||
throw new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"ASAR operation cancelled",
|
||||
);
|
||||
}
|
||||
const frame = pending.at(-1);
|
||||
if (frame === undefined) break;
|
||||
const next = frame.children.next();
|
||||
if (next.done) {
|
||||
pending.pop();
|
||||
continue;
|
||||
}
|
||||
const [name, raw] = next.value;
|
||||
// A header member is one component, not a host path to normalize. POSIX
|
||||
// backslashes remain literal; Windows cannot represent them as components.
|
||||
if (
|
||||
name.length === 0 ||
|
||||
name === "." ||
|
||||
name === ".." ||
|
||||
name.includes("/") ||
|
||||
name.includes("\0") ||
|
||||
(process.platform === "win32" && name.includes("\\"))
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
"ASAR header has an unsafe path component",
|
||||
);
|
||||
const units =
|
||||
frame.parent.length + name.length + (frame.parent.length === 0 ? 0 : 1);
|
||||
// UTF-16 storage, plus records and traversal bookkeeping. Charge every
|
||||
// occurrence even when the engine can share a string prefix.
|
||||
retainedBytes += units * 2 + 256;
|
||||
if (retainedBytes > MAX_LISTING_BYTES)
|
||||
throw new ArtifactReaderFailure(
|
||||
"limit",
|
||||
"ASAR expanded listing exceeds its 64 MiB representation budget",
|
||||
);
|
||||
const path = frame.parent.length === 0 ? name : `${frame.parent}/${name}`;
|
||||
const metadata = nodeSchema.parse(raw);
|
||||
if (
|
||||
metadata.files === undefined &&
|
||||
metadata.link === undefined &&
|
||||
metadata.size === undefined
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"ASAR entry has no directory, link or file metadata",
|
||||
);
|
||||
entries.push({ path, metadata });
|
||||
if (metadata.files !== undefined)
|
||||
pending.push({
|
||||
parent: path,
|
||||
children: ownChildren(metadata.files),
|
||||
});
|
||||
}
|
||||
return entries.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.path, right.path),
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { expect, it } from "vitest";
|
||||
|
||||
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
|
||||
import { captureStorage } from "./CdpCaptureStorage.js";
|
||||
import { CdpCommandRejection } from "./CdpCommandRejection.js";
|
||||
import type { CdpConnection } from "./CdpConnection.js";
|
||||
|
||||
const NO_CACHE_COMMAND = Symbol("unsupported");
|
||||
|
||||
const captureWithCacheResult = async (
|
||||
cacheResult: unknown,
|
||||
): Promise<Awaited<ReturnType<typeof captureStorage>>["value"]> => {
|
||||
const connection = {
|
||||
send(method: string): Promise<unknown> {
|
||||
switch (method) {
|
||||
case "Storage.getUsageAndQuota":
|
||||
return Promise.resolve({ usage: 1, quota: 10 });
|
||||
case "DOMStorage.getDOMStorageItems":
|
||||
return Promise.resolve({ entries: [] });
|
||||
case "IndexedDB.requestDatabaseNames":
|
||||
return Promise.resolve({ databaseNames: [] });
|
||||
case "Network.getCookies":
|
||||
return Promise.resolve({ cookies: [] });
|
||||
case "CacheStorage.requestCacheNames":
|
||||
return cacheResult === NO_CACHE_COMMAND
|
||||
? Promise.reject(
|
||||
new CdpCommandRejection(
|
||||
"inspect_web_page",
|
||||
method,
|
||||
-32601,
|
||||
"Method not found",
|
||||
),
|
||||
)
|
||||
: Promise.resolve(cacheResult);
|
||||
case "CacheStorage.requestEntries":
|
||||
return Promise.resolve({ cacheDataEntries: [], returnCount: 0 });
|
||||
default:
|
||||
return Promise.reject(new Error(`Unexpected command: ${method}`));
|
||||
}
|
||||
},
|
||||
} as unknown as CdpConnection;
|
||||
const { value } = await captureStorage(
|
||||
{
|
||||
connection,
|
||||
sessionId: undefined,
|
||||
input: inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "fixture-page",
|
||||
include_storage_keys: true,
|
||||
include_storage_fingerprints: true,
|
||||
}),
|
||||
},
|
||||
"https://example.test/",
|
||||
[],
|
||||
);
|
||||
return value;
|
||||
};
|
||||
|
||||
it.each([
|
||||
["an observed empty cache list", { caches: [] }, true],
|
||||
["a response without a cache list", {}, false],
|
||||
["a non-array cache list", { caches: "nope" }, false],
|
||||
["a cache list with an unusable entry", { caches: [5] }, false],
|
||||
["an unsupported cache command", NO_CACHE_COMMAND, false],
|
||||
] as const)(
|
||||
"reports fingerprint completeness for %s",
|
||||
async (_label, cacheResult, complete) => {
|
||||
const value = await captureWithCacheResult(cacheResult);
|
||||
expect(value.cache_names).toEqual([]);
|
||||
expect(value.fingerprints_complete).toBe(complete);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
"preserves valid cache evidence beside an unusable entry: %s",
|
||||
async (mixed) => {
|
||||
const cache = { cacheName: "fixture-cache", cacheId: "fixture-cache-id" };
|
||||
const value = await captureWithCacheResult({
|
||||
caches: mixed ? [cache, 5] : [cache],
|
||||
});
|
||||
expect(value.cache_names).toEqual(["fixture-cache"]);
|
||||
expect(value.fingerprints_complete).toBe(!mixed);
|
||||
},
|
||||
);
|
||||
|
|
@ -60,8 +60,8 @@ export const captureStorage = async (
|
|||
limitations,
|
||||
)
|
||||
: undefined;
|
||||
const cacheValues = recordsValue(recordValue(cacheRaw)?.caches);
|
||||
const caches = capturedCaches(cacheValues);
|
||||
const cacheField = recordValue(cacheRaw)?.caches;
|
||||
const caches = capturedCaches(recordsValue(cacheField));
|
||||
const fingerprints = context.input.include_storage_fingerprints
|
||||
? await captureStorageFingerprints({
|
||||
context,
|
||||
|
|
@ -78,8 +78,10 @@ export const captureStorage = async (
|
|||
indexedRaw !== undefined &&
|
||||
Array.isArray(indexedValues) &&
|
||||
indexed.length === indexedValues.length &&
|
||||
cacheRaw !== undefined &&
|
||||
caches.length === cacheValues.length;
|
||||
// Like IndexedDB names, a missing, non-array or partly unusable cache
|
||||
// list is an incomplete enumeration rather than an empty one.
|
||||
Array.isArray(cacheField) &&
|
||||
caches.length === cacheField.length;
|
||||
return {
|
||||
value: {
|
||||
origin,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import {
|
|||
FIELD_RECORD_BUDGET,
|
||||
} from "./parseFieldDiscovery.js";
|
||||
import {
|
||||
BOUNDED_PRODUCER_LIMITS as LIMITS,
|
||||
prlimitArgumentVector,
|
||||
readLinuxResourceLimits,
|
||||
type LinuxResourceLimits,
|
||||
} from "../process/LinuxResourceLimits.js";
|
||||
|
|
@ -60,15 +62,6 @@ export const TSHARK_PROVIDER_IDENTITY = {
|
|||
name: "REA TShark capture adapter",
|
||||
version: "tshark@4",
|
||||
} as const;
|
||||
// Bound the resident input copy and retained text before allocation/collection.
|
||||
const LIMITS = {
|
||||
input_bytes: 32 * 1024 * 1024,
|
||||
output_bytes: 4 * 1024 * 1024,
|
||||
command_timeout_ms: 30_000,
|
||||
address_space_bytes: 1024 * 1024 * 1024,
|
||||
file_bytes: 64 * 1024 * 1024,
|
||||
cpu_seconds: 30,
|
||||
} as const;
|
||||
type Launcher = NonNullable<Parameters<typeof runOwnedCommand>[2]>["launcher"];
|
||||
|
||||
/** Inspect selected capture files only, retaining cleanup ownership across failures. */
|
||||
|
|
@ -165,14 +158,7 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
options?.signal,
|
||||
);
|
||||
effectiveLimits = await this.#resources.readLimits(limits);
|
||||
const limitArguments = [
|
||||
`--as=${effectiveLimits.address_space_bytes}:${effectiveLimits.address_space_bytes}`,
|
||||
`--fsize=${effectiveLimits.file_bytes}:${effectiveLimits.file_bytes}`,
|
||||
`--cpu=${effectiveLimits.cpu_seconds}:${effectiveLimits.cpu_seconds}`,
|
||||
"--core=0:0",
|
||||
"--",
|
||||
command,
|
||||
];
|
||||
const limitArguments = prlimitArgumentVector(effectiveLimits, command);
|
||||
phase = "workspace";
|
||||
root = await this.createRuntime();
|
||||
this.#resources.retain(root, undefined);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import { createApktoolResourceAnalysisProvider } from "../composition/apktool.js
|
|||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { ApktoolOperation } from "../domain/apktool/apktoolResourceAnalysis.js";
|
||||
import {
|
||||
apktoolInputSchemas,
|
||||
type ApktoolOperation,
|
||||
} from "../domain/apktool/apktoolResourceAnalysis.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
|
|
@ -49,22 +52,19 @@ export const registerApktoolCommands = (
|
|||
args: z.object({
|
||||
path: z.string().min(1).describe("Local APK file to decode"),
|
||||
}),
|
||||
// The locale grammar and the string-projection default belong to the
|
||||
// apktool input owner. Read them from that schema rather than restating a
|
||||
// narrower locale pattern, which refused the owner's own advertised
|
||||
// `b+zh+Hans+CN` form at the CLI boundary.
|
||||
options: z.object({
|
||||
locale: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(16)
|
||||
.regex(
|
||||
/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/u,
|
||||
"Locale must look like de or pt-BR",
|
||||
)
|
||||
.optional()
|
||||
.describe(
|
||||
"Project strings from res/values-<locale> instead of the default",
|
||||
),
|
||||
locale: apktoolInputSchemas.decode_android_resources.shape.locale,
|
||||
strings: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.default(
|
||||
apktoolInputSchemas.decode_android_resources.shape.include_strings.parse(
|
||||
undefined,
|
||||
),
|
||||
)
|
||||
.describe(
|
||||
"Include string resources; use --no-strings to skip projecting them",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ const registerInspectionCommand = (
|
|||
description:
|
||||
"Inspect one artifact and return all available observations and next probes",
|
||||
args: z.object({
|
||||
path: z.string().describe("Application or package path"),
|
||||
path: z.string().min(1).describe("Application or package path"),
|
||||
}),
|
||||
options: z.object({
|
||||
integrityPolicy: z
|
||||
|
|
|
|||
|
|
@ -48,7 +48,10 @@ const registerOverviewCommands = (
|
|||
cli.command(CLI_COMMANDS.analyze, {
|
||||
description: "Get an overview of an app",
|
||||
args: z.object({
|
||||
path: z.string().describe("App, program, or analysis database path"),
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("App, program, or analysis database path"),
|
||||
}),
|
||||
options: overviewOptions.extend({
|
||||
integrityPolicy: javascriptApplicationOptions.shape.integrityPolicy,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,46 @@ export const registerCoreNativeCommands = (
|
|||
runDirectAnalysis: DirectAnalysis["runDirectAnalysis"],
|
||||
): void => {
|
||||
registerAnnotationCommand(cli, logger, runDirectAnalysis);
|
||||
cli.command(CLI_COMMANDS.searchBytes, {
|
||||
description: "Find masked byte patterns in initialized provider memory",
|
||||
args: z.object({
|
||||
path: z.string().describe("Local executable path"),
|
||||
pattern: z.string().describe("Hexadecimal byte pairs with ?? wildcards"),
|
||||
}),
|
||||
options: z.object({
|
||||
mask: z.string().optional().describe("Equal-length hexadecimal bit mask"),
|
||||
start: z.string().optional().describe("Inclusive start address"),
|
||||
end: z.string().optional().describe("Inclusive end address"),
|
||||
cursor: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Continuation from the same query's next_cursor"),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(1000)
|
||||
.default(100)
|
||||
.describe("Maximum matches on this page"),
|
||||
...nativeOpenOptionFields,
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.searchBytes, () =>
|
||||
runDirectAnalysis(
|
||||
args.path,
|
||||
"search_bytes",
|
||||
{
|
||||
pattern: args.pattern,
|
||||
limit: options.limit,
|
||||
...(options.mask === undefined ? {} : { mask: options.mask }),
|
||||
...(options.start === undefined ? {} : { start: options.start }),
|
||||
...(options.end === undefined ? {} : { end: options.end }),
|
||||
...(options.cursor === undefined ? {} : { cursor: options.cursor }),
|
||||
},
|
||||
directAnalysisOptionsFromCli(logger, options),
|
||||
),
|
||||
),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.inspectNativeLoadImage, {
|
||||
description:
|
||||
"Verify loaded native bytes, source mappings, relocations and entry",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { compareWasmInterfaces } from "../application/wasm/WasmInterfaceComparisonService.js";
|
||||
import { parseCliJsonInput } from "../cliJsonInput.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { z } from "incur";
|
||||
import { resolve } from "node:path";
|
||||
import { wasmArtifactModule } from "../composition/wasm.js";
|
||||
|
|
@ -13,6 +16,29 @@ export const registerWasmCommands = (
|
|||
logger: Logger,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
): void => {
|
||||
cli.command(CLI_COMMANDS.compareWasmInterfaces, {
|
||||
description:
|
||||
"Compare core WASM interfaces from two exact inspection Evidence records",
|
||||
args: z.object({
|
||||
input: z
|
||||
.string()
|
||||
.describe(
|
||||
"Inline JSON or input file containing left/right complete Evidence and required scope",
|
||||
),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
withCommandCancellation(async (signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.compareWasmInterfaces, async () => {
|
||||
const input = await parseCliJsonInput(
|
||||
args.input,
|
||||
"compare_wasm_interfaces",
|
||||
);
|
||||
if (!input.ok) return input.error;
|
||||
const result = await compareWasmInterfaces(input.value, { signal });
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
}),
|
||||
),
|
||||
});
|
||||
const service = wasmArtifactModule.create(environment);
|
||||
cli.command(wasmArtifactModule.cliCommand, {
|
||||
description: "Inspect an explicit local WASM artifact with WABT",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export const CLI_COMMANDS = Object.freeze({
|
|||
inspectPacketCapture: "inspect-packet-capture",
|
||||
inspectSignalDecoders: "inspect-signal-decoders",
|
||||
decodeSignalRecording: "decode-signal-recording",
|
||||
compareWasmInterfaces: "compare-wasm-interfaces",
|
||||
inspectWasmArtifact: "inspect-wasm-artifact",
|
||||
inspectSqliteDatabase: "inspect-sqlite-database",
|
||||
inspectEvmInterface: "inspect-evm-interface",
|
||||
|
|
@ -39,6 +40,7 @@ export const CLI_COMMANDS = Object.freeze({
|
|||
inspectNativeDataType: "inspect-native-data-type",
|
||||
inspectNativeLoadImage: "inspect-native-load-image",
|
||||
readBytes: "read-bytes",
|
||||
searchBytes: "search-bytes",
|
||||
addressToFileOffset: "address-to-file-offset",
|
||||
traceNativeValues: "trace-native-values",
|
||||
inspectNativeInstruction: "inspect-native-instruction",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,27 @@
|
|||
import { z } from "zod";
|
||||
import { address, document } from "./toolContractHelpers.js";
|
||||
import {
|
||||
bytePatternFields,
|
||||
validateBytePattern,
|
||||
} from "../domain/native/bytePattern.js";
|
||||
|
||||
/** Hopper memory and file-mapping contracts. */
|
||||
export const HOPPER_MEMORY_TOOL_DEFINITIONS = [
|
||||
{
|
||||
name: "search_bytes",
|
||||
description:
|
||||
"Find masked byte signatures in initialized analyzed memory. Hex byte pairs and ?? wildcards are supported; an optional equal-length hex mask selects bits. Bounds are inclusive. Matches are ordered by provider address and never span blocks. Coverage describes this page; reuse the same query with next_cursor to continue. Uninitialized blocks are listed separately. Ghidra only; Hopper is unavailable.",
|
||||
inputSchema: z
|
||||
.strictObject({
|
||||
...bytePatternFields,
|
||||
document,
|
||||
start: address.optional(),
|
||||
end: address.optional(),
|
||||
limit: z.number().int().min(1).max(1000).default(100),
|
||||
cursor: z.string().min(1).optional(),
|
||||
})
|
||||
.superRefine(validateBytePattern),
|
||||
},
|
||||
{
|
||||
name: "read_bytes",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ const resolveReference = (
|
|||
root: Readonly<Record<string, unknown>>,
|
||||
reference: string,
|
||||
): unknown => {
|
||||
if (reference === "#") return root;
|
||||
if (!reference.startsWith("#/")) return undefined;
|
||||
let value: unknown = root;
|
||||
for (const token of reference.slice(2).split("/")) {
|
||||
|
|
@ -229,3 +230,76 @@ export const presentInputJsonSchema = (
|
|||
const described = describeSchema(flattened, flattened, describeProperty);
|
||||
return isObject(described) ? described : flattened;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clients that render advertised inputs into SDK declarations expand
|
||||
* schema-local references with a shared per-input budget: Pi 1.1.0 code-mode
|
||||
* allows 32 expansions and degrades every later reference to `unknown`
|
||||
* (morluto/rea#1632), hiding the fields those references describe. The budget
|
||||
* counts conservatively — every resolvable, non-cyclic reference occurrence
|
||||
* in every schema position — so any reference-resolving client stays within
|
||||
* its measured limit.
|
||||
*/
|
||||
export const advertisedReferenceExpansionBudget = 32;
|
||||
|
||||
const definitionKeys = new Set(["$defs", "definitions"]);
|
||||
|
||||
const countReferenceExpansions = (
|
||||
node: unknown,
|
||||
root: Readonly<Record<string, unknown>>,
|
||||
active: ReadonlySet<string>,
|
||||
count: { value: number },
|
||||
): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((child) =>
|
||||
countReferenceExpansions(child, root, active, count),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isObject(node)) return;
|
||||
if (typeof node.$ref === "string" && !active.has(node.$ref)) {
|
||||
const target = resolveReference(root, node.$ref);
|
||||
if (target !== undefined) {
|
||||
count.value += 1;
|
||||
countReferenceExpansions(
|
||||
target,
|
||||
root,
|
||||
new Set([...active, node.$ref]),
|
||||
count,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (definitionKeys.has(key)) continue;
|
||||
if (schemaMaps.has(key) && isObject(value)) {
|
||||
for (const child of Object.values(value))
|
||||
countReferenceExpansions(child, root, active, count);
|
||||
} else if (schemaArrays.has(key) || schemaChildren.has(key)) {
|
||||
countReferenceExpansions(value, root, active, count);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Conservative client expansion count over every schema position. */
|
||||
export const countLocalReferenceExpansions = (
|
||||
schema: Record<string, unknown>,
|
||||
): number => {
|
||||
const count = { value: 0 };
|
||||
countReferenceExpansions(schema, schema, new Set(), count);
|
||||
return count.value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keep the shared-definition form of an advertised input while clients can
|
||||
* still resolve it. Once rendering would exhaust the reference budget, fall
|
||||
* back to the inline projection, which carries no shared definitions for the
|
||||
* nonrecursive schemas this repository advertises, so every field renders
|
||||
* with complete structure in every reference-resolving client.
|
||||
*/
|
||||
export const inputSchemaWithinReferenceExpansionBudget = (
|
||||
shared: Record<string, unknown>,
|
||||
inline: () => Record<string, unknown>,
|
||||
budget: number,
|
||||
): Record<string, unknown> =>
|
||||
countLocalReferenceExpansions(shared) <= budget ? shared : inline();
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [
|
|||
...toolContractMetadata("inspect_recorded_crash"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Inspect a supplied Linux ELF64 amd64 little-endian core without launching its target or attaching to any live PID. Returns all recorded segments, raw note owners/descriptors and source ranges, every Linux CORE PRSTATUS thread/register and SIGINFO fields inline with SHA-256 Evidence. Historical PID is recorded metadata; current process/file identity, note coverage and signal-thread associations stay unknown. Optionally add derived core-only GDB/pwndbg mapping candidates through explicit BYO configuration; zero map flags mean unknown permissions. Uses the same stable snapshot for both stages, bounded complete output and owned cleanup. Initial Linux x64 profile: unchanged pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2; optional unchanged pwndbg 2026.09.15. No implicit executable/library pairing, GDB command language, host configuration changes or dependency installation.",
|
||||
"Inspect a supplied Linux ELF64 amd64 or AArch64 little-endian core without launching its target or attaching to any live PID. Returns all recorded segments, raw note owners/descriptors and source ranges, every Linux CORE PRSTATUS thread/register and SIGINFO fields inline with SHA-256 Evidence. Historical PID is recorded metadata; current process/file identity, note coverage and signal-thread associations stay unknown. Optionally add derived core-only GDB/pwndbg mapping candidates through explicit BYO configuration; zero map flags mean unknown permissions. Uses the same stable snapshot for both stages, bounded complete output and owned cleanup. Linux x64 host profile: unchanged pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2; optional unchanged pwndbg 2026.09.15. No implicit executable/library pairing, GDB command language, host configuration changes or dependency installation.",
|
||||
inputSchema: inspectRecordedCrashInputSchema,
|
||||
outputSchema: evidenceResultOf(recordedCrashSchema),
|
||||
examples: [
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly<
|
|||
resolve_native_call_targets: { address: "0x1000" },
|
||||
read_function_instructions: { procedure: "main" },
|
||||
read_bytes: { address: "0x1000", length: 16 },
|
||||
search_bytes: { pattern: "48 8B ?? ?? 89" },
|
||||
address_to_file_offset: { address: "0x1000" },
|
||||
procedure_references: { procedure: "main" },
|
||||
procedure_pseudo_code: { procedure: "main" },
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly<Record<string, string>> = {
|
|||
inspect_macho: "runs host Mach-O inspection tools, not the target",
|
||||
inspect_evm_interface:
|
||||
"decodes bytecode in REA's bundled worker without executing it",
|
||||
compare_wasm_interfaces:
|
||||
"compares retained inspection Evidence without executing modules",
|
||||
inspect_wasm_artifact:
|
||||
"WABT validates and decompiles the module without executing it",
|
||||
identify_flutter_build:
|
||||
|
|
|
|||
|
|
@ -438,6 +438,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
|||
inspect_native_data_type: evidence,
|
||||
resolve_native_call_targets: evidence,
|
||||
read_bytes: evidence,
|
||||
search_bytes: evidence,
|
||||
address_to_file_offset: evidence,
|
||||
procedure_references: evidence,
|
||||
procedure_pseudo_code: evidence,
|
||||
|
|
@ -622,6 +623,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
|||
}),
|
||||
compare_process_captures: sessionEvidence,
|
||||
compare_artifacts: sessionEvidence,
|
||||
compare_wasm_interfaces: sessionEvidence,
|
||||
compare_functions: sessionEvidence,
|
||||
compare_bundles: effects({ mutatesSession: true }),
|
||||
find_changed_behavior: sessionEvidence,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ import { Ajv2020 } from "ajv/dist/2020.js";
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { presentInputJsonSchema } from "./inputSchemaPresentation.js";
|
||||
import {
|
||||
advertisedReferenceExpansionBudget,
|
||||
countLocalReferenceExpansions,
|
||||
inputSchemaWithinReferenceExpansionBudget,
|
||||
presentInputJsonSchema,
|
||||
} from "./inputSchemaPresentation.js";
|
||||
import { toolContract } from "./toolContracts.js";
|
||||
import { toolInputSchemaWithMetadata } from "./toolSchemaMetadata.js";
|
||||
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "./javascript/javascriptRuntimeReconciliationExample.js";
|
||||
|
|
@ -166,6 +171,105 @@ describe("advertised input references", () => {
|
|||
);
|
||||
});
|
||||
|
||||
describe("advertised reference expansion budget", () => {
|
||||
it("counts references in keyword-named fields, excluding literals", () => {
|
||||
const fields = ["default", "enum", "examples", "const", "$defs"];
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: Object.fromEntries(
|
||||
fields.map((name) => [name, { $ref: "#/$defs/value" }]),
|
||||
),
|
||||
$defs: { value: { type: "string" } },
|
||||
default: { $ref: "#/$defs/value" },
|
||||
examples: [{ $ref: "#/$defs/value" }],
|
||||
};
|
||||
expect(countLocalReferenceExpansions(schema)).toBe(fields.length);
|
||||
});
|
||||
const reusedComponent = z
|
||||
.object({ kind: z.literal("rec"), value: z.string() })
|
||||
.strict();
|
||||
const inputWithReusedFields = (count: number) =>
|
||||
z.strictObject(
|
||||
Object.fromEntries(
|
||||
Array.from({ length: count }, (_, index) => [
|
||||
`field_${String(index).padStart(2, "0")}`,
|
||||
reusedComponent,
|
||||
]),
|
||||
),
|
||||
);
|
||||
const advertisedInput = (
|
||||
canonical: z.ZodType<Readonly<Record<string, unknown>>>,
|
||||
) =>
|
||||
toolInputSchemaWithMetadata({
|
||||
...toolContract("open_binary"),
|
||||
inputSchema: canonical,
|
||||
})["~standard"].jsonSchema.input({ target: "draft-2020-12" });
|
||||
|
||||
it("keeps shared definitions while every client can resolve them", () => {
|
||||
const advertised = advertisedInput(inputWithReusedFields(8));
|
||||
expect(advertised.$defs).toBeDefined();
|
||||
expect(countLocalReferenceExpansions(advertised)).toBe(8);
|
||||
});
|
||||
|
||||
it("keeps shared definitions exactly at the budget", () => {
|
||||
const advertised = advertisedInput(
|
||||
inputWithReusedFields(advertisedReferenceExpansionBudget),
|
||||
);
|
||||
expect(advertised.$defs).toBeDefined();
|
||||
expect(countLocalReferenceExpansions(advertised)).toBe(
|
||||
advertisedReferenceExpansionBudget,
|
||||
);
|
||||
});
|
||||
|
||||
it("re-projects without shared definitions once the budget is exceeded", () => {
|
||||
const advertised = advertisedInput(
|
||||
inputWithReusedFields(advertisedReferenceExpansionBudget + 1),
|
||||
);
|
||||
expect(advertised.$defs).toBeUndefined();
|
||||
expect(countLocalReferenceExpansions(advertised)).toBe(0);
|
||||
const properties = advertised.properties as Record<string, unknown>;
|
||||
expect(properties.field_00).toMatchObject({
|
||||
type: "object",
|
||||
properties: { kind: { const: "rec" }, value: { type: "string" } },
|
||||
required: ["kind", "value"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the shared form untouched when it fits and swaps it when not", () => {
|
||||
const shared = {
|
||||
type: "object",
|
||||
properties: { value: { $ref: "#/$defs/shared" } },
|
||||
$defs: { shared: { type: "string" } },
|
||||
};
|
||||
const inlined = {
|
||||
type: "object",
|
||||
properties: { value: { type: "string" } },
|
||||
};
|
||||
expect(
|
||||
inputSchemaWithinReferenceExpansionBudget(
|
||||
shared,
|
||||
() => inlined,
|
||||
advertisedReferenceExpansionBudget,
|
||||
),
|
||||
).toBe(shared);
|
||||
expect(
|
||||
inputSchemaWithinReferenceExpansionBudget(
|
||||
{
|
||||
...shared,
|
||||
properties: Object.fromEntries(
|
||||
Array.from({ length: 33 }, (_, index) => [
|
||||
`field_${index}`,
|
||||
{ $ref: "#/$defs/shared" },
|
||||
]),
|
||||
),
|
||||
},
|
||||
() => inlined,
|
||||
advertisedReferenceExpansionBudget,
|
||||
),
|
||||
).toBe(inlined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("root input presentation", () => {
|
||||
it("preserves prototype-named properties in object-root presentation", () => {
|
||||
const fields = Object.fromEntries([
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { byteSearchResultSchema } from "../domain/native/bytePattern.js";
|
||||
import {
|
||||
artifactInventorySummaryViewResultSchema,
|
||||
machoSummaryViewResultSchema,
|
||||
|
|
@ -108,6 +109,7 @@ const bookmarkFacetSchema = z.discriminatedUnion("state", [
|
|||
|
||||
/** Exact structured-content schemas shared by direct analysis providers. */
|
||||
export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
search_bytes: evidenceResultOf(byteSearchResultSchema),
|
||||
annotate_native_function: evidenceResultOf(nativeFunctionAnnotationsSchema),
|
||||
inspect_native_load_image: evidenceResultOf(nativeLoadImageSchema),
|
||||
inspect_native_data_type: evidenceResultOf(nativeDataTypeSchema),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ import { z } from "zod";
|
|||
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
import { advertisedInputExamples } from "./advertisedInputExamples.js";
|
||||
import { presentInputJsonSchema } from "./inputSchemaPresentation.js";
|
||||
import {
|
||||
advertisedReferenceExpansionBudget,
|
||||
inputSchemaWithinReferenceExpansionBudget,
|
||||
presentInputJsonSchema,
|
||||
} from "./inputSchemaPresentation.js";
|
||||
import { digestCanonicalValue } from "../domain/canonicalDigest.js";
|
||||
|
||||
const PROPERTY_DESCRIPTIONS: Readonly<Record<string, string>> = {
|
||||
|
|
@ -66,7 +70,25 @@ export const toolInputSchemaWithMetadata = <Contract extends ToolContract>(
|
|||
});
|
||||
const examples = advertisedInputExamples(contract);
|
||||
return {
|
||||
...presentInputJsonSchema(shared, fallbackPropertyDescription),
|
||||
// Sharing definitions only helps clients that resolve them. A client
|
||||
// rendering declarations from a shared expansion budget degrades
|
||||
// every reference past it to `unknown`, so an input whose shared form
|
||||
// exceeds the budget is re-projected without shared definitions.
|
||||
...inputSchemaWithinReferenceExpansionBudget(
|
||||
presentInputJsonSchema(shared, fallbackPropertyDescription),
|
||||
() =>
|
||||
presentInputJsonSchema(
|
||||
project({
|
||||
...options,
|
||||
libraryOptions: {
|
||||
...options.libraryOptions,
|
||||
reused: "inline",
|
||||
},
|
||||
}),
|
||||
fallbackPropertyDescription,
|
||||
),
|
||||
advertisedReferenceExpansionBudget,
|
||||
),
|
||||
...(examples.length === 0 ? {} : { examples }),
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
|
||||
import { z } from "zod";
|
||||
import { evidenceInputSchema } from "../evidenceInputContracts.js";
|
||||
import { wasmInterfaceSchema } from "../../domain/wasm/wasmInterface.js";
|
||||
import { toolContractMetadata } from "../toolEffects.js";
|
||||
import type { ToolContract } from "../toolContractTypes.js";
|
||||
|
||||
export const compareWasmInterfacesInputSchema = z.strictObject({
|
||||
left: evidenceInputSchema.describe(
|
||||
"Exact complete inspect_wasm_artifact Evidence or retained session reference",
|
||||
),
|
||||
right: evidenceInputSchema.describe(
|
||||
"Exact complete inspect_wasm_artifact Evidence or retained session reference",
|
||||
),
|
||||
scope: z
|
||||
.enum(["imports", "exports", "both"])
|
||||
.describe(
|
||||
"Explicit interface selection; static equality does not establish runtime compatibility",
|
||||
),
|
||||
});
|
||||
const identity = z.object({
|
||||
evidence_id: z.string(),
|
||||
artifact: z.object({
|
||||
path: z.string(),
|
||||
sha256: z.string(),
|
||||
bytes: z.number().int().nonnegative(),
|
||||
}),
|
||||
});
|
||||
export const wasmInterfaceComparisonSchema = z.object({
|
||||
left: identity,
|
||||
right: identity,
|
||||
scope: z.enum(["imports", "exports", "both"]),
|
||||
coverage: z.enum(["complete-within-inputs", "partial", "unknown"]),
|
||||
changes: z.array(
|
||||
z.object({
|
||||
direction: z.enum(["import", "export"]),
|
||||
module: z.string().nullable(),
|
||||
name: z.string(),
|
||||
state: z.enum(["added", "removed", "changed", "unchanged", "unknown"]),
|
||||
left: wasmInterfaceSchema.shape.entries,
|
||||
right: wasmInterfaceSchema.shape.entries,
|
||||
reason: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
summary: z.object({
|
||||
added: z.number(),
|
||||
removed: z.number(),
|
||||
changed: z.number(),
|
||||
unchanged: z.number(),
|
||||
unknown: z.number(),
|
||||
}),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
export const COMPARE_WASM_INTERFACES_CONTRACT = {
|
||||
name: "compare_wasm_interfaces",
|
||||
...toolContractMetadata("compare_wasm_interfaces"),
|
||||
kind: "application",
|
||||
description:
|
||||
"Compare typed core WASM import/export names, kinds and exact signatures from two authenticated inspect_wasm_artifact Evidence records. Select imports, exports or both explicitly. Retained references resolve only in the current session; CLI accepts portable inline Evidence. Duplicate names, unsupported types and missing historical interface facets remain unknown. Preserve original module digests and every occurrence; no source acquisition, module execution or runtime-compatibility inference.",
|
||||
inputSchema: compareWasmInterfacesInputSchema,
|
||||
outputSchema: evidenceResultOf(wasmInterfaceComparisonSchema),
|
||||
examples: [],
|
||||
} as const satisfies ToolContract;
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { COMPARE_WASM_INTERFACES_CONTRACT } from "./wasmComparisonContracts.js";
|
||||
import {
|
||||
inspectWasmArtifactInputSchema,
|
||||
wasmArtifactSchema,
|
||||
|
|
@ -40,4 +41,7 @@ export const INSPECT_WASM_ARTIFACT_CONTRACT = {
|
|||
} as const satisfies ToolContract;
|
||||
|
||||
/** Keep the public catalog in its existing order, using its named contract. */
|
||||
export const WASM_TOOL_CONTRACTS = [INSPECT_WASM_ARTIFACT_CONTRACT] as const;
|
||||
export const WASM_TOOL_CONTRACTS = [
|
||||
INSPECT_WASM_ARTIFACT_CONTRACT,
|
||||
COMPARE_WASM_INTERFACES_CONTRACT,
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -417,6 +417,34 @@ describe("Apple bundle layouts and containers", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Apple IPA components", () => {
|
||||
it("excludes AppleDouble sidecars from IPA components", () => {
|
||||
const result = project(
|
||||
inventoryEvidence("ipa", "Fixture.ipa", [
|
||||
{ path: "Payload/Fixture.app/Info.plist" },
|
||||
macho("Payload/Fixture.app/Fixture"),
|
||||
{ path: "Payload/Fixture.app/Contents/Resources/._libfoo.dylib" },
|
||||
{ path: "Payload/Fixture.app/Frameworks/Foo.framework/._Foo" },
|
||||
{ path: "__MACOSX/Payload/Fixture.app/libghost.dylib" },
|
||||
macho("Payload/Fixture.app/Contents/Resources/libreal.dylib"),
|
||||
macho("SwiftSupport/iphoneos/libswiftCore.dylib"),
|
||||
]),
|
||||
);
|
||||
expect(result.components.native_libraries.map(({ path }) => path)).toEqual([
|
||||
"Payload/Fixture.app/Contents/Resources/libreal.dylib",
|
||||
"SwiftSupport/iphoneos/libswiftCore.dylib",
|
||||
]);
|
||||
expect(result.components.frameworks).toEqual([]);
|
||||
expect(result.components.executables.map(({ path }) => path)).toEqual([
|
||||
"Payload/Fixture.app/Contents/Resources/libreal.dylib",
|
||||
"Payload/Fixture.app/Fixture",
|
||||
]);
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("AppleDouble sidecar entries"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("macOS application scope", () => {
|
||||
it("attributes only components inside the application roots", () => {
|
||||
const result = project(
|
||||
|
|
|
|||
|
|
@ -218,8 +218,9 @@ export const appleProjectionId = (projection: unknown): string =>
|
|||
`aap_${digest(projection)}`;
|
||||
|
||||
/**
|
||||
* IPA projection keeps every archive component, as before. Directory, ZIP,
|
||||
* and DMG inventories can hold installers and other siblings, so only
|
||||
* AppleDouble sidecars describe neighbouring files and are never components.
|
||||
* IPA projection keeps every other archive component, as before. Directory,
|
||||
* ZIP, and DMG inventories can hold installers and other siblings, so only
|
||||
* components inside an application root are attributed to it.
|
||||
*/
|
||||
const attributeComponents = (
|
||||
|
|
@ -227,8 +228,8 @@ const attributeComponents = (
|
|||
roots: readonly string[],
|
||||
rootFormat: RootFormat,
|
||||
): { readonly all: Component[]; readonly unattributed: number } => {
|
||||
if (rootFormat === "ipa") return { all: [...inventoried], unattributed: 0 };
|
||||
const visible = inventoried.filter(({ path }) => !isSidecar(path));
|
||||
if (rootFormat === "ipa") return { all: visible, unattributed: 0 };
|
||||
const all = visible.filter(({ path }) =>
|
||||
roots.some((root) => isWithin(path, root)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { addressCoordinate } from "./native/addressCoordinate.js";
|
||||
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
|
||||
|
|
@ -38,16 +39,6 @@ export const addressDistance = (start: string, end: string): number => {
|
|||
);
|
||||
};
|
||||
|
||||
const addressCoordinate = (
|
||||
value: string,
|
||||
): { readonly space: string; readonly offset: bigint } | null => {
|
||||
const separator = value.lastIndexOf(":0x");
|
||||
const space = separator < 0 ? "default" : value.slice(0, separator);
|
||||
const offset = separator < 0 ? value : value.slice(separator + 1);
|
||||
if (!/^0x[0-9a-f]+$/u.test(offset)) return null;
|
||||
return { space, offset: BigInt(offset) };
|
||||
};
|
||||
|
||||
const procedureMapSchema = z.record(z.string(), z.string());
|
||||
const addressedNamesSchema = z.array(
|
||||
z.object({ address: z.string(), name: z.string() }),
|
||||
|
|
@ -128,9 +119,9 @@ export const functionBodySchema = z.discriminatedUnion("available", [
|
|||
})
|
||||
.superRefine((body, context) => {
|
||||
let total = 0n;
|
||||
const bounds = new Map<string, { start: bigint; end: bigint }>();
|
||||
const closedSpaces = new Set<string>();
|
||||
let previousSpace: string | null = null;
|
||||
const bounds = new Map<string | null, { start: bigint; end: bigint }>();
|
||||
const closedSpaces = new Set<string | null>();
|
||||
let previousSpace: string | null | undefined;
|
||||
for (const range of body.ranges) {
|
||||
const start = addressCoordinate(range.start);
|
||||
const end = addressCoordinate(range.end);
|
||||
|
|
@ -146,7 +137,7 @@ export const functionBodySchema = z.discriminatedUnion("available", [
|
|||
});
|
||||
return;
|
||||
}
|
||||
if (previousSpace !== null && previousSpace !== start.space)
|
||||
if (previousSpace !== undefined && previousSpace !== start.space)
|
||||
closedSpaces.add(previousSpace);
|
||||
const previous = bounds.get(start.space);
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -339,7 +339,10 @@ const spawnOptions = (candidate: SpawnCandidate): t.ObjectExpression | null => {
|
|||
const spawnArgvCount = (candidate: SpawnCandidate): number | null => {
|
||||
if (candidate.method === "exec") return null;
|
||||
const args = candidate.node.arguments[1];
|
||||
return t.isArrayExpression(args) ? args.elements.length : null;
|
||||
return t.isArrayExpression(args) &&
|
||||
!args.elements.some((element) => t.isSpreadElement(element))
|
||||
? args.elements.length
|
||||
: null;
|
||||
};
|
||||
|
||||
const objectHasProperty = (
|
||||
|
|
|
|||
|
|
@ -429,7 +429,12 @@ export const addSourceMapDirectives = (
|
|||
end === null
|
||||
)
|
||||
continue;
|
||||
const commentSource = source.slice(start, end);
|
||||
// The parser owns comment boundaries. Exclude only the closing block
|
||||
// delimiter; an asterisk elsewhere remains part of the declared URL.
|
||||
const commentSource = source.slice(
|
||||
start,
|
||||
comment.type === "CommentBlock" ? end - 2 : end,
|
||||
);
|
||||
if (
|
||||
!(commentSource.startsWith("//") || commentSource.startsWith("/*")) ||
|
||||
!(commentSource[2] === "#" || commentSource[2] === "@")
|
||||
|
|
@ -449,9 +454,7 @@ export const addSourceMapDirectives = (
|
|||
const urlOffset = commentSource.slice(equalsStart + 1).search(/\S/u);
|
||||
if (urlOffset === -1) continue;
|
||||
const declaredStart = equalsStart + 1 + urlOffset;
|
||||
const terminator = commentSource
|
||||
.slice(declaredStart)
|
||||
.search(commentSource.startsWith("//") ? /\s/u : /[\s*]/u);
|
||||
const terminator = commentSource.slice(declaredStart).search(/\s/u);
|
||||
const declaredEnd =
|
||||
terminator === -1 ? commentSource.length : declaredStart + terminator;
|
||||
if (declaredEnd === declaredStart) continue;
|
||||
|
|
|
|||
|
|
@ -72,10 +72,10 @@ describe("source map directives", () => {
|
|||
it("preserves Unicode whitespace and URL terminator boundaries", () => {
|
||||
const source =
|
||||
"ğ/*@\u00a0sourceMappingURL\u2003=\ufeffapp.js.map* ignored */";
|
||||
const declaredEnd = source.indexOf("* ignored");
|
||||
const declaredEnd = source.indexOf(" ignored");
|
||||
expect(analyzeJavaScriptStaticSource(source).source_map_urls).toEqual([
|
||||
{
|
||||
declared_url: "app.js.map",
|
||||
declared_url: "app.js.map*",
|
||||
location: {
|
||||
start: { line: 1, column: 1 },
|
||||
end: { line: 1, column: declaredEnd },
|
||||
|
|
@ -84,6 +84,29 @@ describe("source map directives", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/*#", "app*part.js.map", " */"],
|
||||
["/*@", "*part.js.map", "*/"],
|
||||
["/*#", "app.js.map*", "*/"],
|
||||
["/*#", "**part.js.map", " */"],
|
||||
["/*#", "app.js.map", "*/"],
|
||||
["//#", "app*part.js.map", ""],
|
||||
])("preserves the URL and range in %s %s%s", (marker, url, suffix) => {
|
||||
const directive = marker + " sourceMappingURL=" + url;
|
||||
const result = analyzeJavaScriptStaticSource(
|
||||
"export const value = 1;\n" + directive + suffix,
|
||||
);
|
||||
expect(result.source_map_urls).toEqual([
|
||||
{
|
||||
declared_url: url,
|
||||
location: {
|
||||
start: { line: 2, column: 0 },
|
||||
end: { line: 2, column: directive.length },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports nothing when no directive is present", () => {
|
||||
expect(sourceMapUrls("const value = 1;")).toEqual([]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
/** Parse canonical native offsets without discarding their address space. */
|
||||
export const addressCoordinate = (
|
||||
value: string,
|
||||
): { readonly space: string | null; readonly offset: bigint } | null => {
|
||||
const separator = value.lastIndexOf(":0x");
|
||||
if (separator === 0) return null;
|
||||
const space = separator < 0 ? null : value.slice(0, separator);
|
||||
const offset = separator < 0 ? value : value.slice(separator + 1);
|
||||
if (!/^0x[0-9a-f]+$/u.test(offset)) return null;
|
||||
return { space, offset: BigInt(offset) };
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { parseBytePattern } from "./bytePattern.js";
|
||||
|
||||
describe("masked byte patterns", () => {
|
||||
it("preserves whole-byte wildcards and combines explicit bit masks", () => {
|
||||
const parsed = parseBytePattern("48 8b\t??89", "ff f0 ff 0f");
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
expect(parsed.value).toEqual({
|
||||
bytes: [0x48, 0x8b, 0, 0x89],
|
||||
masks: [255, 240, 0, 15],
|
||||
});
|
||||
const exact = parseBytePattern("Aa\u00a000");
|
||||
if (!exact.ok) throw exact.error;
|
||||
expect(exact.value).toEqual({ bytes: [170, 0], masks: [255, 255] });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", undefined, "pattern"],
|
||||
["4", undefined, "pattern"],
|
||||
["4?", undefined, "pattern"],
|
||||
["gg", undefined, "pattern"],
|
||||
["?? ??", undefined, "pattern"],
|
||||
["ff", "00", "pattern"],
|
||||
["ffff", "ff", "mask"],
|
||||
["ff", "??", "mask"],
|
||||
])("rejects invalid pattern %s with typed issues", (pattern, mask, field) => {
|
||||
const parsed = parseBytePattern(pattern, mask);
|
||||
if (parsed.ok) throw new Error("Expected a rejected pattern");
|
||||
expect(parsed.error.issues).toMatchObject([
|
||||
{ path: [field], reason: "invalid_value" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import { z } from "zod";
|
||||
import { addressCoordinate } from "./addressCoordinate.js";
|
||||
import { AnalysisInputError } from "../analysisErrorCore.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
|
||||
/** Byte pairs, with whole-byte wildcards; explicit masks can select individual bits. */
|
||||
export const bytePatternFields = {
|
||||
pattern: z
|
||||
.string()
|
||||
.regex(/^([0-9a-fA-F]{2}|\?\?)(\s*([0-9a-fA-F]{2}|\?\?))*$/u),
|
||||
mask: z
|
||||
.string()
|
||||
.regex(/^[0-9a-fA-F]{2}(\s*[0-9a-fA-F]{2})*$/u)
|
||||
.optional(),
|
||||
};
|
||||
|
||||
/** Parse once at the input boundary, retaining typed correction issues. */
|
||||
export const parseBytePattern = (
|
||||
pattern: string,
|
||||
mask?: string,
|
||||
): Result<{ bytes: number[]; masks: number[] }, AnalysisInputError> => {
|
||||
const invalid = (field: string, message: string) =>
|
||||
err(
|
||||
new AnalysisInputError("search_bytes", undefined, [
|
||||
{ path: [field], reason: "invalid_value", message },
|
||||
]),
|
||||
);
|
||||
if (!bytePatternFields.pattern.safeParse(pattern).success)
|
||||
return invalid("pattern", "Use hexadecimal byte pairs or ?? wildcards");
|
||||
if (mask !== undefined && !bytePatternFields.mask.safeParse(mask).success)
|
||||
return invalid("mask", "Use hexadecimal mask byte pairs");
|
||||
const pairs = pattern.replace(/\s/gu, "").match(/../gu) ?? [];
|
||||
const maskPairs = mask?.replace(/\s/gu, "").match(/../gu);
|
||||
if (maskPairs && maskPairs.length !== pairs.length)
|
||||
return invalid("mask", "Mask length must equal pattern length");
|
||||
const bytes = pairs.map((pair) =>
|
||||
pair === "??" ? 0 : Number.parseInt(pair, 16),
|
||||
);
|
||||
const masks = pairs.map((pair, index) =>
|
||||
pair === "??" ? 0 : Number.parseInt(maskPairs?.[index] ?? "ff", 16),
|
||||
);
|
||||
if (!masks.some((value) => value !== 0))
|
||||
return invalid("pattern", "Pattern must constrain at least one bit");
|
||||
return ok({ bytes, masks });
|
||||
};
|
||||
|
||||
/** Shared semantic validation for public and provider inputs. */
|
||||
export const validateBytePattern = (
|
||||
value: { pattern: string; mask?: string | undefined },
|
||||
context: z.RefinementCtx,
|
||||
): void => {
|
||||
const parsed = parseBytePattern(value.pattern, value.mask);
|
||||
if (!parsed.ok)
|
||||
for (const issue of parsed.error.issues)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: [...issue.path],
|
||||
message: issue.message ?? "Invalid byte pattern",
|
||||
});
|
||||
};
|
||||
|
||||
const coverageBlock = z
|
||||
.strictObject({
|
||||
segment: z.string(),
|
||||
start: z.string(),
|
||||
end: z.string(),
|
||||
bytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const start = addressCoordinate(value.start);
|
||||
const end = addressCoordinate(value.end);
|
||||
if (
|
||||
start === null ||
|
||||
end === null ||
|
||||
start.space !== end.space ||
|
||||
start.offset > end.offset ||
|
||||
end.offset - start.offset + 1n !== BigInt(value.bytes)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Byte coverage must equal its inclusive address range in one space",
|
||||
});
|
||||
});
|
||||
|
||||
/** Inclusive page coverage; matches never span memory blocks. */
|
||||
export const byteSearchResultSchema = z
|
||||
.strictObject({
|
||||
matches: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
address: z.string(),
|
||||
segment: z.string(),
|
||||
file_offset: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.nullable(),
|
||||
procedure: z
|
||||
.strictObject({ address: z.string(), name: z.string() })
|
||||
.nullable(),
|
||||
}),
|
||||
)
|
||||
.max(1000),
|
||||
searched: z.strictObject({
|
||||
blocks: z.array(coverageBlock),
|
||||
bytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
||||
skipped_uninitialized: z.array(coverageBlock),
|
||||
skipped_not_loaded: z.array(coverageBlock),
|
||||
}),
|
||||
complete: z.boolean(),
|
||||
next_cursor: z.string().min(1).nullable(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (
|
||||
value.complete !== (value.next_cursor === null) ||
|
||||
value.searched.bytes !==
|
||||
value.searched.blocks.reduce((sum, block) => sum + block.bytes, 0)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Byte search coverage and continuation disagree",
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { providerSchema } from "../evidence.js";
|
||||
|
||||
import {
|
||||
compareUnicodeCodePoints,
|
||||
|
|
@ -115,6 +116,7 @@ export const nativeMetadataEvidenceSchema = z.strictObject({
|
|||
"decompiler",
|
||||
]),
|
||||
description: z.string().min(1),
|
||||
provider: providerSchema.optional(),
|
||||
location: nativeMetadataLocationSchema,
|
||||
artifact_path: z.string().min(1).nullable().default(null),
|
||||
artifact_sha256: z
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
inspectRecordedCrashInputSchema,
|
||||
recordedCrashSchema,
|
||||
type RecordedCrash,
|
||||
} from "./recordedCrash.js";
|
||||
import {
|
||||
recordedCrashFixture,
|
||||
|
|
@ -282,3 +283,95 @@ it("keeps absent map flags unknown and validates reported permission bits", () =
|
|||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
describe("recorded AArch64 core profile", () => {
|
||||
it("accepts the complete AArch64 register window in ABI order", () => {
|
||||
const value = recordedCrashSchema.parse(
|
||||
recordedCrashFixture("/selected/aarch64.core", "aarch64-little-endian"),
|
||||
);
|
||||
expect(value.architecture).toBe("aarch64-little-endian");
|
||||
const registers = value.threads[0]?.registers ?? [];
|
||||
expect(registers.map(({ name }) => name)).toEqual([
|
||||
...Array.from({ length: 31 }, (_, index) => `x${String(index)}`),
|
||||
"sp",
|
||||
"pc",
|
||||
"pstate",
|
||||
]);
|
||||
expect(registers[14]?.value).toBe("0x1122334455667788");
|
||||
expect(registers.every(({ location }) => location.bytes === "0x8")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the AArch64 note ranges inside their recorded segment", () => {
|
||||
const value = recordedCrashSchema.parse(
|
||||
recordedCrashSignalFixture(undefined, "aarch64-little-endian"),
|
||||
);
|
||||
const note = value.notes[0];
|
||||
const segment = value.segments[0];
|
||||
if (note === undefined || segment === undefined)
|
||||
throw new Error("Expected recorded notes");
|
||||
expect(
|
||||
BigInt(note.location.offset) + BigInt(note.location.bytes),
|
||||
).toBeLessThanOrEqual(BigInt(segment.offset) + BigInt(segment.file_size));
|
||||
expect(value.signals[0]?.fault_address).toBe("0x1122334455667788");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"an amd64 register name inside an AArch64 window",
|
||||
(v: RecordedCrash) => ({
|
||||
...v,
|
||||
threads: v.threads.map((thread) => ({
|
||||
...thread,
|
||||
registers: thread.registers.map((register, index) =>
|
||||
index === 0 ? { ...register, name: "rax" } : register,
|
||||
),
|
||||
})),
|
||||
}),
|
||||
],
|
||||
[
|
||||
"the amd64 register count",
|
||||
(v: RecordedCrash) => ({
|
||||
...v,
|
||||
threads: v.threads.map((thread) => ({
|
||||
...thread,
|
||||
registers: thread.registers.slice(0, 27),
|
||||
})),
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a 4-byte AArch64 register source",
|
||||
(v: RecordedCrash) => ({
|
||||
...v,
|
||||
threads: v.threads.map((thread) => ({
|
||||
...thread,
|
||||
registers: thread.registers.map((register, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...register,
|
||||
location: { ...register.location, bytes: "0x4" },
|
||||
}
|
||||
: register,
|
||||
),
|
||||
})),
|
||||
}),
|
||||
],
|
||||
] as const)("rejects %s", (_name, change) => {
|
||||
const value = recordedCrashFixture(
|
||||
"/selected/aarch64.core",
|
||||
"aarch64-little-endian",
|
||||
);
|
||||
expect(recordedCrashSchema.safeParse(change(value)).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an architecture outside the interpreted profiles", () => {
|
||||
const value = recordedCrashFixture();
|
||||
expect(
|
||||
recordedCrashSchema.safeParse({
|
||||
...value,
|
||||
architecture: "arm-little-endian",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import type { BinaryArchitecture } from "../binaryTargetTypes.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import { CANONICAL_BASE64_PATTERN } from "../stringPatterns.js";
|
||||
import { recordedNoteCoverageIssues } from "./recordedCrashNoteCoverage.js";
|
||||
|
|
@ -18,7 +19,7 @@ const diagnostics = z.strictObject({
|
|||
const LINUX_CORE_OWNER_BASE64 = "Q09SRQA=";
|
||||
|
||||
/** Complete register identities in the supported Linux amd64 PRSTATUS ABI order. */
|
||||
export const RECORDED_LINUX_AMD64_REGISTERS: readonly string[] = [
|
||||
const RECORDED_LINUX_AMD64_REGISTERS: readonly string[] = [
|
||||
"r15",
|
||||
"r14",
|
||||
"r13",
|
||||
|
|
@ -47,7 +48,95 @@ export const RECORDED_LINUX_AMD64_REGISTERS: readonly string[] = [
|
|||
"fs",
|
||||
"gs",
|
||||
];
|
||||
const supportedRegisters = new Set(RECORDED_LINUX_AMD64_REGISTERS);
|
||||
const RECORDED_LINUX_AARCH64_REGISTERS: readonly string[] = [
|
||||
"x0",
|
||||
"x1",
|
||||
"x2",
|
||||
"x3",
|
||||
"x4",
|
||||
"x5",
|
||||
"x6",
|
||||
"x7",
|
||||
"x8",
|
||||
"x9",
|
||||
"x10",
|
||||
"x11",
|
||||
"x12",
|
||||
"x13",
|
||||
"x14",
|
||||
"x15",
|
||||
"x16",
|
||||
"x17",
|
||||
"x18",
|
||||
"x19",
|
||||
"x20",
|
||||
"x21",
|
||||
"x22",
|
||||
"x23",
|
||||
"x24",
|
||||
"x25",
|
||||
"x26",
|
||||
"x27",
|
||||
"x28",
|
||||
"x29",
|
||||
"x30",
|
||||
"sp",
|
||||
"pc",
|
||||
"pstate",
|
||||
];
|
||||
|
||||
/** Recorded-core architectures whose Linux note ABI is interpreted. */
|
||||
export const RECORDED_LINUX_CORE_ARCHITECTURES = [
|
||||
"x86_64-little-endian",
|
||||
"aarch64-little-endian",
|
||||
] as const;
|
||||
|
||||
export type RecordedCrashArchitecture =
|
||||
(typeof RECORDED_LINUX_CORE_ARCHITECTURES)[number];
|
||||
|
||||
/**
|
||||
* ABI facts for one recorded-core architecture, measured from unchanged pwntools
|
||||
* 4.15.0 `elf_prstatus_*` and `user_regs_struct_*` structures and mirrored by
|
||||
* `bridge/pwntools/recorded_crash_impl.py`. Both profiles place `pr_cursig` at
|
||||
* 12, `pr_pid` at 32 and the register window at 112; the ELF machine, the window
|
||||
* contents and the Evidence CPU family differ per profile.
|
||||
*/
|
||||
export const RECORDED_LINUX_CORE_PROFILES: Record<
|
||||
RecordedCrashArchitecture,
|
||||
{
|
||||
readonly machine: number;
|
||||
readonly evidenceArchitecture: BinaryArchitecture;
|
||||
readonly registers: readonly string[];
|
||||
readonly registerBytes: number;
|
||||
readonly registerOffset: number;
|
||||
readonly prstatusBytes: number;
|
||||
}
|
||||
> = {
|
||||
"x86_64-little-endian": {
|
||||
machine: 62,
|
||||
evidenceArchitecture: "x86_64",
|
||||
registers: RECORDED_LINUX_AMD64_REGISTERS,
|
||||
registerBytes: 8,
|
||||
registerOffset: 112,
|
||||
prstatusBytes: 336,
|
||||
},
|
||||
"aarch64-little-endian": {
|
||||
machine: 183,
|
||||
evidenceArchitecture: "arm64",
|
||||
registers: RECORDED_LINUX_AARCH64_REGISTERS,
|
||||
registerBytes: 8,
|
||||
registerOffset: 112,
|
||||
prstatusBytes: 392,
|
||||
},
|
||||
};
|
||||
|
||||
const recordedCoreRegisterSets: Record<
|
||||
RecordedCrashArchitecture,
|
||||
ReadonlySet<string>
|
||||
> = {
|
||||
"x86_64-little-endian": new Set(RECORDED_LINUX_AMD64_REGISTERS),
|
||||
"aarch64-little-endian": new Set(RECORDED_LINUX_AARCH64_REGISTERS),
|
||||
};
|
||||
|
||||
/** Select an explicit recording and optionally request debugger-derived mapping context. */
|
||||
export const inspectRecordedCrashInputSchema = z.strictObject({
|
||||
|
|
@ -108,7 +197,7 @@ const recordedCrashObjectSchema = z.strictObject({
|
|||
bytes: index,
|
||||
}),
|
||||
format: z.literal("elf-core"),
|
||||
architecture: z.literal("x86_64-little-endian"),
|
||||
architecture: z.enum(RECORDED_LINUX_CORE_ARCHITECTURES),
|
||||
target_execution: z.literal("not-performed"),
|
||||
live_process_identity: z.literal("unknown"),
|
||||
segments: z.array(
|
||||
|
|
@ -284,6 +373,8 @@ export const recordedCrashSchema = recordedCrashObjectSchema.superRefine(
|
|||
"Each supported raw note requires exactly one corresponding interpretation.",
|
||||
);
|
||||
}
|
||||
const profile = RECORDED_LINUX_CORE_PROFILES[value.architecture];
|
||||
const profileRegisters = recordedCoreRegisterSets[value.architecture];
|
||||
for (const thread of value.threads) {
|
||||
const note = value.notes[thread.note_index];
|
||||
if (
|
||||
|
|
@ -295,8 +386,8 @@ export const recordedCrashSchema = recordedCrashObjectSchema.superRefine(
|
|||
continue;
|
||||
}
|
||||
if (
|
||||
thread.registers.length !== RECORDED_LINUX_AMD64_REGISTERS.length ||
|
||||
!thread.registers.every(({ name }) => supportedRegisters.has(name)) ||
|
||||
thread.registers.length !== profile.registers.length ||
|
||||
!thread.registers.every(({ name }) => profileRegisters.has(name)) ||
|
||||
new Set(thread.registers.map(({ name }) => name)).size !==
|
||||
thread.registers.length
|
||||
)
|
||||
|
|
@ -305,7 +396,7 @@ export const recordedCrashSchema = recordedCrashObjectSchema.superRefine(
|
|||
);
|
||||
for (const register of thread.registers)
|
||||
if (
|
||||
BigInt(register.location.bytes) !== 8n ||
|
||||
BigInt(register.location.bytes) !== BigInt(profile.registerBytes) ||
|
||||
!within(
|
||||
register.location,
|
||||
BigInt(note.descriptor_location.offset),
|
||||
|
|
|
|||
|
|
@ -143,3 +143,13 @@ export const completeReconstructionCoverageData = () => {
|
|||
],
|
||||
});
|
||||
};
|
||||
|
||||
/** Complete workspace with its first verifier result and boundary narrowed once. */
|
||||
export const completeReconstructionCoverageFixture = () => {
|
||||
const workspace = completeReconstructionCoverageData();
|
||||
const result = workspace.verifier_results[0];
|
||||
const boundary = workspace.boundaries[0];
|
||||
if (result === undefined || boundary === undefined)
|
||||
throw new Error("Expected complete reconstruction coverage fixture");
|
||||
return { workspace, result, boundary };
|
||||
};
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue