merge: keep completed xrefs when ledger retention is refused

Producer-required xref Evidence still enters the ledger, including
one-shot snapshot context. A capacity refusal keeps that completed
observation on the error instead of returning only the ledger failure.
Stored pointer classifications stay on the literal trace reference.
This commit is contained in:
Cursor Agent committed 2026-11-04 19:46:06 +00:00
commit baec1f459f
418 files changed
+20213 -2556

No files matched your search

@@ -2,7 +2,7 @@
name: reverse-engineer-anything
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA, and build agent environments (AgentEnv bundles) of web applications from a live instance or a capture. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
metadata:
version: "59"
version: "60"
---
# REA
@@ -121,6 +121,15 @@ explicit path or endpoint and do not need it.
cleanup effects, and do not require a preliminary static session unless their
own contract says so.
- User-owned browser page already open: `list_browser_targets`.
- Passive traffic capture of a SPA: the default scope is the page's own origin,
and data APIs usually live on another origin. Discover API origins from
absolute URLs in loaded bundles or script text via JavaScript analysis tools
(another passive capture without those origins still scopes to the page
origin, and `completeness.excluded` counts lack URLs). Pass both the page
origin and those API origins in `allowed_origins` — the list replaces the
default scope — and read `completeness.excluded` before reporting that an
API is absent. For long captures with body shapes, use
`export_evidence_bundle` instead of expecting an inline result.
- Browser action the agent must perform (click, type, navigate):
`capture_browser_scenario` with declared actions. See
[runtime observation](references/runtime-observation.md) for connect/launch
@@ -708,6 +708,13 @@ cache one decoded block and support retained filtered/paged views. No
decryption or SerializedFile object decoding is provided. See
[UnityFS](https://github.com/morluto/rea/blob/main/docs/unityfs.md).
WeChat mini-program packages (`.wxapkg` files with `0xBE`/`0xED` marks) list
members with declared names and absolute carrier offsets; member bytes are
stored verbatim. `V1MMWX` PC-client packages report `encrypted` directory
status with unknown coverage and are refused on extraction; no decryption is
attempted. See
[wxapkg](https://github.com/morluto/rea/blob/main/docs/wxapkg.md).
U-Boot legacy uImages use the same inspection/extraction workflows. Prefer a
retained summary and selected inventory views for header facts or component
IDs. Header addresses/CPU/OS are declarations; payloads remain as stored.
+90 -52
View File
@@ -179,6 +179,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
private List<FunctionEntry> procedureInventory;
private List<InventoryItem> stringInventory;
private DecompInterface decompiler;
// A refused Program may only report its admission failure to the client.
private String identityAdmissionFailure;
/** Per-function native decompiler limit from the session; 0 leaves it unbounded. */
private int decompileTimeoutSeconds;
// DecompileProcess converts seconds to an int millisecond timer delay.
@@ -224,14 +226,17 @@ public final class ReaGhidraBridge extends HeadlessScript {
throw new IllegalStateException("Ghidra provider version does not match the session");
}
String importedSha256 = currentProgram.getExecutableSHA256();
if (importedSha256 == null ||
!constantTimeEquals(
importedSha256.toLowerCase(Locale.ROOT),
descriptor.targetSha256
)) {
throw new IllegalStateException(
"Ghidra imported-byte digest does not match the admitted target"
);
if (importedSha256 == null || !importedSha256.matches("(?i)[0-9a-f]{64}")) {
identityAdmissionFailure = "import_identity_unavailable";
}
else if (!constantTimeEquals(importedSha256.toLowerCase(Locale.ROOT),
descriptor.targetSha256)) {
identityAdmissionFailure = "import_identity_mismatch";
}
if (identityAdmissionFailure != null) {
end(false);
serve(descriptor);
return;
}
admittedTargetSha256 = descriptor.targetSha256;
// GhidraScript wraps run() in a transaction. End it before serving so
@@ -405,6 +410,14 @@ public final class ReaGhidraBridge extends HeadlessScript {
writeSuccess(writer, request.id, result);
return;
}
if (identityAdmissionFailure != null) {
// Keep the producer alive until the client has consumed the
// refusal and closed or sent shutdown. Every other request
// remains refused, including further startup attempts.
writeFailure(writer, request.id, identityAdmissionFailure,
"Ghidra imported identity admission failed");
continue;
}
decompileTimeoutSeconds = request.decompileTimeoutSeconds() == null
? descriptor.decompileTimeoutSeconds() : request.decompileTimeoutSeconds();
writeSuccess(writer, request.id, handleRequest(request, descriptor));
@@ -771,6 +784,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
return result;
}
private record ByteSearchBlock(MemoryBlock block, Address low, Address high) {}
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();
@@ -831,16 +846,13 @@ public final class ReaGhidraBridge extends HeadlessScript {
JsonArray blocks = new JsonArray();
JsonArray skipped = new JsonArray();
JsonArray notLoaded = new JsonArray();
long covered = 0;
String next = null;
List<ByteSearchBlock> eligible = new ArrayList<>();
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;
@@ -852,40 +864,66 @@ public final class ReaGhidraBridge extends HeadlessScript {
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) {
eligible.add(new ByteSearchBlock(block, low, high));
}
long covered = 0;
String next = null;
for (int first = 0; first < eligible.size() && next == null;) {
monitor.checkCancelled();
int last = first;
while (last + 1 < eligible.size()) {
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();
ByteSearchBlock previous = eligible.get(last);
ByteSearchBlock following = eligible.get(last + 1);
if (!previous.high().getAddressSpace().equals(following.low().getAddressSpace()) ||
previous.high().next() == null || !previous.high().next().equals(following.low())) break;
last++;
}
JsonObject coverage = byteSearchCoverage(block, low, examinedEnd);
covered = Math.addExact(covered, coverage.get("bytes").getAsLong());
blocks.add(coverage);
Address low = eligible.get(first).low();
Address high = eligible.get(last).high();
if (resume != null && low.compareTo(resume) < 0) low = resume;
if (low.compareTo(high) <= 0 && low.getAddressSpace().equals(high.getAddressSpace())) {
Address at = low;
Address examinedEnd = high;
while (at != null && at.compareTo(high) <= 0 && high.subtract(at) >= bytes.length - 1L) {
monitor.checkCancelled();
// findBytes bounds candidate starts. The entire candidate remains in this eligible run.
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;
}
MemoryBlock matchBlock = currentProgram.getMemory().getBlock(found);
JsonObject match = new JsonObject();
match.addProperty("address", canonicalAddress(found));
match.addProperty("segment", matchBlock.getName());
Long offset = targetFileOffset(matchBlock, 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();
}
for (int index = first; index <= last; index++) {
monitor.checkCancelled();
ByteSearchBlock item = eligible.get(index);
Address coveredLow = item.low().compareTo(low) < 0 ? low : item.low();
Address coveredHigh = item.high().compareTo(examinedEnd) > 0 ? examinedEnd : item.high();
if (coveredLow.compareTo(coveredHigh) > 0) continue;
JsonObject coverage = byteSearchCoverage(item.block(), coveredLow, coveredHigh);
covered = Math.addExact(covered, coverage.get("bytes").getAsLong());
blocks.add(coverage);
}
}
first = last + 1;
}
JsonObject searched = new JsonObject();
searched.add("blocks", blocks);
@@ -1010,7 +1048,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
} catch (RequestFailure failure) {
// Existing names/addresses win. Only an unresolved numeric input
// can fall back to source coordinates; ambiguity never falls back.
if (!failure.code.equals("not_found") || !selector.matches("0x[0-9a-fA-F]+"))
if (!failure.code.equals("procedure_not_found") || !selector.matches("0x[0-9a-fA-F]+"))
throw failure;
}
}
@@ -1037,17 +1075,17 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
List<Address> candidates = new ArrayList<>(mappedAddresses);
if (candidates.size() > 1)
throw new RequestFailure("ambiguous", "File offset " + coordinate +
throw new RequestFailure("file_offset_ambiguous", "File offset " + coordinate +
" has multiple loaded mappings: " + candidates.stream().map(ReaGhidraBridge::canonicalAddress).toList() +
"; select an explicit analysis address");
if (candidates.isEmpty())
throw new RequestFailure("not_found", "File offset " + coordinate +
throw new RequestFailure("file_offset_unmapped", "File offset " + coordinate +
" has no loaded source mapping for the admitted target file; Ghidra image base: " + canonicalAddress(currentProgram.getImageBase()) +
". Inspect mappings with rea inspect-native-load-image <path> --provider ghidra --json or MCP inspect_native_load_image.");
Address mapped = candidates.get(0);
Function function = functionAtOrContaining(mapped);
if (function == null)
throw new RequestFailure("not_found", "File offset " + coordinate +
throw new RequestFailure("procedure_not_found", "File offset " + coordinate +
" maps to analysis address " + canonicalAddress(mapped) +
", but no procedure starts at or contains that address in the current Ghidra analysis. " +
"Choose an observed procedure with CLI rea search <path> . --kind procedures --mode regex --provider ghidra --json or MCP list_procedures.");
@@ -2458,7 +2496,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
if (matchProcedureNames(entry, selector).matched()) matches.add(entry.function);
}
if (matches.size() > 1)
throw new RequestFailure("ambiguous", "Ghidra procedure name is ambiguous: " + value +
throw new RequestFailure("procedure_ambiguous", "Ghidra procedure name is ambiguous: " + value +
"; select an exact entry address from " + matches.stream()
.map(function -> canonicalAddress(function.getEntryPoint())).toList());
if (matches.size() == 1) return matches.get(0);
@@ -2466,11 +2504,11 @@ public final class ReaGhidraBridge extends HeadlessScript {
// Retain legacy bare-hex address input only when no symbol matches.
Address address = explicitAddress ? parseReaAddress(value) : tryParseAddress(value);
if (address == null)
throw new RequestFailure("not_found", "Unknown Ghidra procedure name or address: " + value);
throw new RequestFailure("procedure_not_found", "Unknown Ghidra procedure name or address: " + value);
Function function = functionAtOrContaining(address);
if (function == null)
// Other procedure operations retain their analysis-address semantics.
throw new RequestFailure("not_found", "No procedure exists at the requested address: " + value +
throw new RequestFailure("procedure_not_found", "No procedure exists at the requested address: " + value +
"; Ghidra image base: " + canonicalAddress(currentProgram.getImageBase()) +
". Choose an observed analysis address. CLI: rea search <path> . --kind procedures --mode regex --provider ghidra --json; " +
"MCP: list_procedures. Inspect the loaded base and source mappings with CLI rea analyze <path> --provider ghidra --json " +
@@ -4916,7 +4954,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
// without retaining its cause. This producer reason is not bad syntax.
if (exception.getDescription().equals("Stack overflow during pattern compilation"))
throw regexStackExhausted(expression, "compiling");
throw new RequestFailure("invalid_request", "Invalid regex pattern " + expression + ": " + exception.getDescription() + " at index " + exception.getIndex());
throw new RequestFailure("invalid_regex_pattern", "Invalid regex pattern " + expression + ": " + exception.getDescription() + " at index " + exception.getIndex());
}
catch (StackOverflowError exhausted) {
throw regexStackExhausted(expression, "compiling");
+13 -15
View File
@@ -26,7 +26,6 @@ ARGUMENT_REGISTERS = {
"x86_64h": ["rdi", "rsi", "rdx", "rcx", "r8", "r9"],
}
MAX_SELECTOR_BYTES = 1024
MAX_REPORTED_LOCATIONS = 64
MAX_OTHER_STOPS = 256
MAX_TRACE_BYTES = 8 * 1024 * 1024
MAX_TRACE_FRAMES = 65_536
@@ -505,20 +504,19 @@ def _breakpoint_report(target, created, config):
if not _wanted(config["breakpoints"][index], location["symbol"] or ""):
continue
count += 1
if len(locations) < MAX_REPORTED_LOCATIONS:
remaining_bytes = (
MAX_BREAKPOINT_LOCATION_BYTES - retained_location_bytes
)
if not _fits_trace_budget(location, remaining_bytes):
locations_truncated = True
continue
location_bytes = len(json.dumps(location).encode("utf-8"))
addition = location_bytes + (1 if locations else 0)
if addition > remaining_bytes:
locations_truncated = True
continue
locations.append(location)
retained_location_bytes += addition
remaining_bytes = (
MAX_BREAKPOINT_LOCATION_BYTES - retained_location_bytes
)
if not _fits_trace_budget(location, remaining_bytes):
locations_truncated = True
continue
location_bytes = len(json.dumps(location).encode("utf-8"))
addition = location_bytes + (1 if locations else 0)
if addition > remaining_bytes:
locations_truncated = True
continue
locations.append(location)
retained_location_bytes += addition
report.append({"index": index, "location_count": count, "locations": locations})
return report, locations_truncated
+28
View File
@@ -430,6 +430,34 @@ class NativeTraceBudgetTests(unittest.TestCase):
self.assertEqual(report[0]["locations"], [])
self.assertFalse(locations_truncated)
def test_breakpoint_report_keeps_all_locations_under_aggregate_budget(self):
class Location:
def __init__(self, index):
self.index = index
def GetAddress(self):
return self.index
class Breakpoint:
def GetNumLocations(self):
return 65
def GetLocationAtIndex(self, index):
return Location(index)
with patch.object(
tracer,
"_location",
side_effect=lambda address, _target: {"address": address, "symbol": None},
), patch.object(tracer, "_wanted", return_value=True):
report, locations_truncated = tracer._breakpoint_report(
object(), [[Breakpoint()]], {"breakpoints": [{}]}
)
self.assertEqual(report[0]["location_count"], 65)
self.assertEqual(len(report[0]["locations"]), 65)
self.assertFalse(locations_truncated)
def test_breakpoint_report_bounds_aggregate_location_json(self):
tracer.lldb.LLDB_INVALID_ADDRESS = -1
symbol_name = (
+23 -22
View File
@@ -228,6 +228,22 @@ func processRunTokenObservation(
parse: Result<ParsedRunToken, RunTokenReadError>,
codeSigningFlags: () -> UInt32?
) -> RunTokenObservation {
// Native inspection restrictions apply even when argv is incomplete or
// overwritten, before parsing reaches the environment. Keep this fact at
// the native boundary; cleanup still decides whether the process is related.
func withheldEnvironment() -> RunTokenObservation? {
guard let flags = codeSigningFlags() else { return nil }
if (flags & csPlatformBinary) != 0 {
return unavailable(pid: pid, reason: "platform_binary_environment_withheld")
}
if (flags & csRestrict) != 0 {
return unavailable(pid: pid, reason: "restricted_process_environment_withheld")
}
return nil
}
func unavailableEnvironment(_ reason: String) -> RunTokenObservation {
withheldEnvironment() ?? unavailable(pid: pid, reason: reason)
}
switch parse {
case .success(let parsed):
if let runId = parsed.runId {
@@ -238,40 +254,25 @@ func processRunTokenObservation(
if parsed.environmentEntryCount > 0 {
return RunTokenObservation(pid: pid, state: "readable", run_id: nil, reason: nil)
}
let flags = codeSigningFlags()
if let flags, (flags & csPlatformBinary) != 0 {
return unavailable(pid: pid, reason: "platform_binary_environment_withheld")
}
if let flags, (flags & csRestrict) != 0 {
return unavailable(pid: pid, reason: "restricted_process_environment_withheld")
}
if let withheld = withheldEnvironment() { return withheld }
return RunTokenObservation(pid: pid, state: "readable", run_id: nil, reason: nil)
case .failure(let failure):
switch failure {
case .environmentUnavailable:
let flags = codeSigningFlags()
if let flags, (flags & csPlatformBinary) != 0 {
return unavailable(pid: pid, reason: "platform_binary_environment_withheld")
}
if let flags, (flags & csRestrict) != 0 {
return unavailable(pid: pid, reason: "restricted_process_environment_withheld")
}
return unavailable(pid: pid, reason: "environment_unavailable")
return unavailableEnvironment("environment_unavailable")
case .ambiguousEnvironmentBoundary:
if let flags = codeSigningFlags(), (flags & csRestrict) != 0 {
return unavailable(pid: pid, reason: "restricted_process_environment_withheld")
}
return unavailable(pid: pid, reason: "ambiguous_environment_boundary")
return unavailableEnvironment("ambiguous_environment_boundary")
// These failures already establish that token bytes were available.
case .duplicateToken:
return unavailable(pid: pid, reason: "duplicate_run_token")
case .invalidTokenEncoding:
return unavailable(pid: pid, reason: "invalid_run_token_encoding")
case .appleVectorUnavailable:
return unavailable(pid: pid, reason: "apple_vector_unavailable")
return unavailableEnvironment("apple_vector_unavailable")
case .invalidBuffer:
return unavailable(pid: pid, reason: "malformed_procargs")
return unavailableEnvironment("malformed_procargs")
case .invalidBufferAt(let stage):
return unavailable(pid: pid, reason: "malformed_procargs_\(stage.rawValue)")
return unavailableEnvironment("malformed_procargs_\(stage.rawValue)")
}
}
}
+6 -2
View File
@@ -203,8 +203,12 @@ and during shutdown.
`uninstall_adb_package` declare `mutatesTarget` and never run implicitly:
pushes check for existing device files before transfer and refuse them
unless `overwrite` is set. A failed or unexpected existence probe stops
transfer even with `overwrite`; only a clean `test -e` exit of 1 with no
output is interpreted as absence. The check is not atomic, so a different
transfer even with `overwrite`. A completed `test -e` exit of 1 establishes
absence only with empty stdout and either empty stderr or a recognized,
completed adb-server startup notice sequence. Startup notices are host
observations, not remote-path diagnostics; successful push receipts retain
them in `raw_result.device_path_probes`. Incomplete startup, extra diagnostics,
truncation and cancellation do not establish absence. The check is not atomic, so a different
process can create the path before `adb push` writes it. When available, the device's
`sha256sum` verifies the transfer. A missing command or output without a
recognizable digest reports `device_digest_source: "unavailable"` with an
+3
View File
@@ -73,6 +73,9 @@ spellings; the original SDK declaration strings remain unchanged.
it remains null for unknown provider target SDKs, nonliteral declarations, or
components with filters targeting API 31 or later that omit the now-required
explicit declaration. A limitation records the latter validation error.
As in Android's package parser, an activity, alias or receiver filter without
an `<action>` is discarded and does not count toward either rule; a service
keeps such a filter. `intent_filter_count` still counts every declared filter.
Provider authorities and all inventory dimensions are bounded. `status:
"partial"` and `limitations` identify omitted declarations or values; a
capacity limitation appears only when an actual collection limit was reached.
+4 -1
View File
@@ -9,7 +9,10 @@ liveness deadlines. `INPUT_JSON` may be inline JSON or a JSON file path.
The minimal request contains only browser launch/connect selection, `start_url`,
and at least one `actions` entry. Launch `headless` defaults to `true` and can
be set to `false` where the host supports a visible browser. Navigations follow
be set to `false` where the host supports a visible browser. Launched Chromium
keeps its sandbox; on hosts that cannot provide one, such as root in a
container, set `REA_BROWSER_NO_SANDBOX=true` for the REA process. Termux on
Android always launches without it. Navigations follow
the destinations named by the scenario and the page's ordinary browser requests,
and [request steps](#request-steps) send only to the start URL's origin;
there is no separate origin allowlist. Request steps use a temporary dispatch
+38 -1
View File
@@ -39,6 +39,17 @@ pass an absolute path. REA reads and authenticates the file, then projects it
like retained Evidence, so a large file is bounded by the file reader rather
than charged as inline input.
An argument that accepts inline JSON or a JSON file path also accepts `-`, which
reads the request from standard input, so a generated request needs no
temporary file:
```bash
jq -nc '{source: {kind: "evidence-file", path: "app-evidence.json"}, view: {kind: "summary"}}' | rea inspect-analysis-view -
```
Standard input is read once as a single UTF-8 JSON document without a
byte-order mark, and it is limited to 8 MiB. Put a larger request in a file.
Local file inspection commands reject empty paths and NUL bytes before resolving
relative inputs, auxiliary files or output destinations. An empty string never
selects the working directory; use an explicit `.` when selecting that directory
@@ -270,6 +281,17 @@ Format versions 6 through 8 retain header, block and directory facts with
none/LZ4/LZ4HC block decompression. See [UnityFS](unityfs.md) for encryption
and LZMA coverage boundaries.
Inspect a WeChat mini-program package without WeChat or an unpacker:
```sh
rea inspect-artifact app.wxapkg --detail summary --evidence_output evidence.json --json
rea extract-artifact app.wxapkg --json
```
Plaintext packages retain header, table and member facts with verbatim member
bytes. `V1MMWX` packages are detected as encrypted coverage and refused on
extraction. See [wxapkg](wxapkg.md) for layout and coverage boundaries.
Inspect an ESP32-family app or bootloader image without esptool:
```sh
@@ -612,6 +634,9 @@ Saved snapshots include only cache entries whose subject identifies the selected
target. Unbound observations and observations of child artifacts remain available
in the open session and its Evidence bundle; they are excluded from the target's
saved replay entries without changing their subjects.
The snapshot also retains available Evidence linked by selected records, including
transitive sources with an unbound or different subject. Those sources are not
replay entries for the selected target. Unrelated records remain excluded.
Snapshots retain eligible target-scoped question histories in full, including
mutation Evidence recorded while another target was active. Observations and
@@ -626,6 +651,11 @@ rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/pat
```
An exact CLI cache hit is normally read before starting a provider process.
`decompile --provider ghidra --snapshot ...` also retains its single selected
Program's pseudocode result, even without an explicit document selector. Old
snapshots that contain only the Evidence need one new query to add that replay
entry. This does not retain every body from `decompile-all` or make a persistent
session's implicit document replayable.
On Windows, Ghidra first admits the selected source through its native snapshot
owner, so replay can start that provider.
In MCP, `open_binary` accepts `snapshot_path` to import a snapshot atomically
@@ -678,6 +708,10 @@ results and Evidence. These are cached observations, not a saved provider
database: only eligible exact queries can reuse a result, and new or live
queries can still require provider startup. The CLI uses the same snapshot
format and preserves these records when loading and updating it.
When an MCP session replays a provider observation, its delivered Evidence identifies
the original observation through `evidence_links` and reports that the
provider was not rerun. The original and its available source records remain
in the investigation bundle.
## Import, export and compare Evidence
@@ -1185,9 +1219,12 @@ treated as malformed.
exact array path with `--filter-output` and provide comma-separated direct
scalar columns with `--columns`; column order is the output column order:
For an artifact inventory request that selects the `occurrences` collection
and includes `logical_path` and `artifact_id` in its view fields:
```bash
rea inspect-analysis-view request.json --format csv \
--filter-output normalized_result.rows --columns name,address
--filter-output normalized_result.items --columns logical_path,artifact_id
```
The first line is a REA metadata record,
+31 -15
View File
@@ -310,6 +310,22 @@ Kimi Code also discovers personal skills in `~/.agents/skills` and project-local
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.
## LM Studio
For [LM Studio](https://lmstudio.ai/docs/app/mcp), setup writes `mcpServers.rea`
into `mcp.json` in the LM Studio home directory. Setup finds that directory as
LM Studio does: the path stored in `~/.lmstudio-home-pointer`, otherwise
`~/.cache/lm-studio` when it exists, otherwise `~/.lmstudio`. Setup only reads
the pointer file. LM Studio parses `mcp.json` as strict JSON, so setup rejects
a file with comments, trailing commas, or a byte order mark without a backup
or a write. The entry carries `command`, `args`, and `env`, and setup keeps
every other server. Local models often have small context windows, so setup
selects `REA_MCP_INPUT_SCHEMA_PROFILE=compact` unless the stored entry or the
setup environment selects a profile explicitly. Doctor reports an entry with no
explicit profile as stale, as for Kimi Code. LM Studio 0.4.26 on macOS loaded
this registration, and a local model called an REA tool through it. Linux and
Windows remain unverified.
## Pi
For [Pi](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/mcp.md),
@@ -632,21 +648,20 @@ writing a registration ZCode cannot load.
## FreeBuff
For FreeBuff, the proposed integration writes `mcpServers.rea` into the user-scope
`~/.agents/mcp.json` shared-agent file. Entries carry `command`, `args`, and
`env`; stdio is the default transport. The proposed personal skill location is
`~/.agents/skills`, so setup installs its skill copy there. Because the same
For FreeBuff, `rea setup --client freebuff` writes `mcpServers.rea` into the
user-scope `~/.agents/mcp.json` shared-agent file. Entries carry `command`,
`args`, and `env`; stdio is the default transport. The personal skill location
is `~/.agents/skills`, so setup installs its skill copy there. Because the same
file doubles as ZCode's user-scope fallback (see above), a `freebuff`
registration is also in the file ZCode uses until ZCode defines its own native server.
Setup and doctor detect FreeBuff from its own profile directory
(`~/.config/freebuff-desktop`), not from the shared `.agents` folder that
REA's skill installs create for every client.
registration is also in the file ZCode uses until ZCode defines its own native
server. Setup and doctor detect FreeBuff from its own profile directory
(`~/.config/freebuff-desktop`), not from the shared `.agents` folder that REA's
skill installs create for every client.
The configuration write and personal skill locations are proposed integration
boundaries. FreeBuff MCP loading and skill discovery have not been verified
with an installed native FreeBuff client/version; filesystem tests cover REA's
editing, discovery, and ownership behavior only. Automatic applicability
requires the FreeBuff profile directory even when the shared MCP file exists.
FreeBuff MCP loading and skill discovery have not been verified with an
installed native FreeBuff client/version; filesystem tests cover REA's editing,
discovery, and ownership behavior only. Automatic applicability requires the
FreeBuff profile directory even when the shared MCP file exists.
When uninstall cannot establish FreeBuff or another shared-storage reader from
product discovery, a malformed or unreadable shared MCP file is retained
unchanged and does not stop other REA removals, including `--purge-data`. When
@@ -654,8 +669,9 @@ FreeBuff or ZCode product applicability is established or unresolved, the same
malformed shared file stops uninstall before removals. See
[Diagnose, update, and remove](installation.md#diagnose-update-and-remove).
FreeBuff setup support requires `rea-agents@6.8.0` or later. Older versions
reject `rea setup --client freebuff` as an unsupported client. See the
FreeBuff setup support requires `rea-agents@6.8.0` or later (including the
current npm `latest` release). Older versions reject
`rea setup --client freebuff` as an unsupported client. See the
[installation guide](installation.md#supported-agents) for client availability.
## DeepSeek Harness
+3 -3
View File
@@ -121,9 +121,9 @@ launch environment affects the next REA process.
is the byte-budget shape: a decimal safe integer with a 10 MiB floor
(`MINIMUM_RESPONSE_BYTES`), the same shared schema, and no silent fallback. A
change needs a REA restart and a matching client receive buffer. It does not
raise REA's reserved string limit or Node's string ceiling. The setup
allowlist does not copy this setting. A new headroom setting should, following
the regex deadline.
raise REA's reserved string limit or Node's string ceiling. Like the regex
deadline, it is in `SELECTED_REGISTRATION_SETTINGS`, so setup forwards an
explicit selection into the client registration.
A per-call budget can have a runtime default without having an environment
setting. JavaScript `max_heap_mb`
+9
View File
@@ -110,6 +110,15 @@ not establish full metadata validity or correspondence to a native binary;
version 24 inspection still applies only to supported header layouts. See
[IL2CPP metadata](il2cpp-metadata.md).
In a managed directory with a Unity Mono witness, `Assembly-CSharp.dll` and
`Assembly-CSharp-firstpass.dll` get an `inspect_managed_members` probe listed
first; project-named asmdef assemblies keep only the ordinary managed probes.
The same probe is offered for those names in `Managed/` of a
`<Product>_BackUpThisFolder_ButDontShipItWithYourGame` folder (or when that
folder is the root); whether its IL matches the shipped IL2CPP code is unknown.
Point the scan at the game folder: a root that is the `<Product>_Data` or
`Managed` folder itself has no Mono witness path and gets no member probe.
Candidates are derived or inferred from these facts and do not prove loading or
execution. BepInEx, MelonLoader, Easy Anti-Cheat and BattlEye labels identify
path conventions only; runtime presence stays unknown. No bypass is provided.
+7 -1
View File
@@ -38,6 +38,12 @@ were examined; an unavailable section name does not erase those note facts.
For GNU notes, `gnu.build_id_hex`, `gnu.abi_tag`, or `gnu.gold_version` are
filled when the descriptor matches that type. Other owners keep `gnu` null.
For Linux `CORE` notes, `core.prpsinfo`, `core.file`, `core.siginfo`, or
`core.auxv` are decoded for process arguments and status (`NT_PRPSINFO`), mapped
file memory regions and paths (`NT_FILE`), fault signal and code (`NT_SIGINFO`), and
auxiliary vectors (`NT_AUXV`) across 32-bit and 64-bit images. Other owners or unparsed
notes keep `core` null.
`PT_NOTE` and `SHT_NOTE` are examined independently. The same file range may
appear twice when both tables declare it.
@@ -48,7 +54,7 @@ the image never carried notes in another container or overlay.
The selected file is limited to 64 MiB by default (512 MiB maximum). At most
32 note entries are examined by default across every table. Retained note
names, section names, and GNU descriptor text share a scan budget based on
names, section names, GNU descriptor text, and core dump string representations share a scan budget based on
the source bytes plus any decoded section-name table, capped at
`max_file_bytes`. The raw source and decoded table must also fit together
within that ceiling. If admission fails, the error reports the required
+6 -3
View File
@@ -245,9 +245,12 @@ tree semantics, vulnerability scanning, emulation or hardware runtime capture.
3, allowed range 1–10.
Staging budgets are polled and rechecked after exit; short-lived overshoot is
possible. The per-file process limit is the larger of the selected payload budget
and the fixed report/diagnostic file allowances, so a small payload selection
still permits the mandatory report. A tighter inherited host limit still applies.
possible. The per-file process limit is one byte above the larger of the selected
payload budget and the fixed report/diagnostic file allowances, so a small payload
selection still permits the mandatory report, and a staged file the kernel stops
at the limit exceeds its budget instead of passing as complete. A tighter
inherited host limit still applies and can remove that byte, so a staged file
that reaches the installed limit is rejected as possibly truncated.
The inherited file-size limit bounds each file, not total storage;
address-space limits are per process, not an aggregate memory quota. Worker
count is not CPU affinity for every external extractor. These limits do not
+5 -3
View File
@@ -330,9 +330,11 @@ 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).
Overlapping matches are included, including across adjacent eligible initialized
blocks in the same address space. Gaps and excluded or uninitialized blocks break
a search run. Each match reports its address, starting segment, unique source file
offset for that starting address (or null), and containing procedure (or null).
A starting offset does not imply the full match is contiguous in the file.
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`
+27
View File
@@ -25,6 +25,33 @@ by default; `--integrity-policy record-and-continue` retains the observed bytes
and an explicit untrusted contradiction. An all-zero MD5 is an unknown claim,
not a verified digest.
Artifact comparison pairs members by `logical_path`, the stored path without
`res://`, and compares their bytes and occurrence facts. It does not compare
`godot-pck-entry` metadata. Godot 4.3
[stores `res://` paths](https://github.com/godotengine/godot/blob/77dcf97d82cbfe4e4615475fa52ca03da645dbd8/editor/export/editor_export_platform.cpp#L228)
and [keys members by them](https://github.com/godotengine/godot/blob/77dcf97d82cbfe4e4615475fa52ca03da645dbd8/core/io/file_access_pack.cpp#L50-L52).
Godot 4.4.1
[stores paths without the prefix](https://github.com/godotengine/godot/blob/49a5bc7b616bd04689a2c89e89bda41f50241464/editor/export/editor_export_platform.cpp#L257-L260)
and [removes it before keying](https://github.com/godotengine/godot/blob/49a5bc7b616bd04689a2c89e89bda41f50241464/core/io/file_access_pack.cpp#L48-L50).
Each version finds `res://scenes/main.gd` in its own export, but comparing the
stored `source_path` would mark every member changed between such exports.
`logical_path` names the member Godot's lookup finds, except in two cases.
REA normalizes Unicode to NFC, so a member renamed only between Unicode forms
compares unchanged, although Godot treats it as another path. Godot reads `\`
as `/` and REA keeps it literal, so a member spelled with backslashes compares
as removed and added. Directory order and offsets are layout. The encryption
bit, the only member flag REA admits, is the occurrence's `encrypted` field. A
member whose encryption changes compares `unknown` in `metadata` and
`availability`: the change is known, but REA does not read encrypted bytes,
so whether the script changed is unknown. Godot's exporter
[stores the MD5 of a member's original bytes](https://github.com/godotengine/godot/blob/a13da4feb8d8aefc283c3763d33a2f170a18d541/editor/export/editor_export_platform.cpp#L465-L468),
as the 4.3 and 4.4.1 exporters do, so the declared MD5 does not depend on the
encryption IV. Encrypted members whose declared MD5s differ therefore also
compare `unknown` in `metadata` and `availability`: the MD5s declare
different scripts, but REA cannot verify them, and equal MD5s add nothing for
the same reason. An encrypted member with an MD5 on only one side stays
`unknown`.
## CLI
Start with a compact summary and keep the complete Evidence bundle:
+20
View File
@@ -36,6 +36,8 @@ The MCP tool `project_harmony_application_graph` takes the same
- exact component paths, artifact IDs, and hashes (manifests, Ark bytecode,
resources, native libraries, JavaScript, signing)
- `unclassified_components` with the same paths and content identities for
authenticated files outside those categories and the App Pack child list
- a packaging-model hint: Stage is claimed only from an exactly cased `module.json` entry
outside a classified resource path, FA only from `config.json` outside a
classified resource path, otherwise `unknown`
@@ -55,6 +57,24 @@ being classified as code files. Ark and JavaScript runtime hints summarize the
classified bytecode and JavaScript lists; supported source extensions share the
same classification rule as their component rows.
`unclassified_components` preserves files such as `pkgContextInfo.json`,
`.pages.info`, a HarmonyOS `.har` shared library, and a non-certificate
`META-INF` entry without interpreting their contents. The `.har` suffix also
names HTTP Archive JSON, so identifying one needs verified ZIP bytes rather than
a path rule. Different paths sharing the same bytes remain separate entries. App
Pack children of an App Pack root are already reported in `app_pack_children`,
so they are not repeated as unclassified files. A nested `.hap` or `.hsp` member
of a HAP or HSP root is disclosed in `unclassified_components` instead, because
child-package inventory is not recursive. Unreadable or encrypted content remains
in `unavailable_components` with unknown identity instead.
`coverage.status: "complete-within-inventory"` describes the authenticated
source inventory and bounded bridge projection, not semantic understanding of
every file. Unclassified files do not make a complete inventory partial; missing
inventory pages, source coverage limits, unavailable content and bridge limits
still qualify coverage independently. An empty unclassified list on a partial
inventory does not establish that the whole package has no unclassified files.
Every claim is path-presence only. Manifest semantics (module identity,
abilities, entry points, permissions, dependencies), Ark bytecode contents and
versions, resource resolution, signing verification, and observed native
+4 -1
View File
@@ -36,7 +36,10 @@ regions and are never filled.
Region `format_metadata` (`hex-record-region`) records `start_address`,
`end_address`, `length`, `record_count`, and `first_line`/`last_line`: the
1-based line span of the contributing data records.
1-based line span of the contributing data records. `compare_artifacts`
compares none of these fields: a region's name encodes its start and end
addresses, its length follows from them, and record and line counts are
layout.
Root `format_metadata` (`hex-records`) records:
+13 -13
View File
@@ -47,19 +47,19 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md).
## Choose a guide
| Task | Guides |
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) |
| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [PE TLS/load config](pe-load-config.md), [ELF notes](elf-notes.md), [Linux kernel modules](kernel-modules.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) |
| Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [GameCube DOL](ghidra-dol.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) |
| Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) |
| Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md) |
| Inspect assemblies, packages, or firmware | [Godot PCK](godot-pck.md), [UnityFS](unityfs.md), [U-Boot uImage](uimage.md), [.NET](managed-code-analysis.md), [Python packages](python-packaged-applications.md), [Android](android-analysis.md), [APK signatures](apk-signature.md), [Apktool resources](apktool-resource-analysis.md), [ADB acquisition](adb-device-analysis.md), [Apple](apple-application-analysis.md), [Harmony](harmony-analysis.md), [firmware](firmware-analysis.md), [ESP images](esp-image.md), [ESP flash images](esp-flash-image.md), [WASM](wasm-artifacts.md), [EVM](evm-bytecode.md) |
| Observe or compare a browser scenario | [CDP observation](browser-observation.md), [scenarios](browser-scenario-contract.md), [runtime evidence](web-runtime.md), [historical captures](web-network-captures.md) |
| Serve recorded behavior as an agent environment | [Agent environments](agent-environments.md) |
| Trace captured website source | [Source locations](web-source-location.md), [module traces](website-module-trace.md), [script export](website-script-export.md) |
| Capture and compare a command | [Process capture](process-capture.md) |
| Use CLI/MCP and retain evidence | [CLI](cli.md), [MCP contracts](mcp-contracts.md), [agent prompts](mcp-prompts.md), [obligation ledgers](reconstruction-obligation-ledgers.md) |
| Task | Guides |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) |
| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [PE TLS/load config](pe-load-config.md), [ELF notes](elf-notes.md), [Linux kernel modules](kernel-modules.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) |
| Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [GameCube DOL](ghidra-dol.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) |
| Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) |
| Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md) |
| Inspect assemblies, packages, or firmware | [Godot PCK](godot-pck.md), [UnityFS](unityfs.md), [wxapkg](wxapkg.md), [U-Boot uImage](uimage.md), [.NET](managed-code-analysis.md), [Python packages](python-packaged-applications.md), [Android](android-analysis.md), [APK signatures](apk-signature.md), [Apktool resources](apktool-resource-analysis.md), [ADB acquisition](adb-device-analysis.md), [Apple](apple-application-analysis.md), [Harmony](harmony-analysis.md), [firmware](firmware-analysis.md), [ESP images](esp-image.md), [ESP flash images](esp-flash-image.md), [WASM](wasm-artifacts.md), [EVM](evm-bytecode.md) |
| Observe or compare a browser scenario | [CDP observation](browser-observation.md), [scenarios](browser-scenario-contract.md), [runtime evidence](web-runtime.md), [historical captures](web-network-captures.md) |
| Serve recorded behavior as an agent environment | [Agent environments](agent-environments.md) |
| Trace captured website source | [Source locations](web-source-location.md), [module traces](website-module-trace.md), [script export](website-script-export.md) |
| Capture and compare a command | [Process capture](process-capture.md) |
| Use CLI/MCP and retain evidence | [CLI](cli.md), [MCP contracts](mcp-contracts.md), [agent prompts](mcp-prompts.md), [obligation ledgers](reconstruction-obligation-ledgers.md) |
For development, use [testing](testing.md), [client verification](client-verification.md),
[tool design](tool-design.md), [admission limits and recovery](design/limits-and-recovery.md),
+43 -9
View File
@@ -378,12 +378,12 @@ a particular caller-supplied input will be valid. See
Select the part you want to check:
| Task | Readiness check |
| ----------------------------------- | ------------------------------------------------------------------------ |
| Static JavaScript/Electron analysis | Run `rea analyze-javascript-application PATH --json` directly. |
| One analysis engine | `rea doctor --provider ghidra --json` (or `hopper`, `ida`, `kuna`) |
| One agent registration | `rea doctor --client codex --json` (see [client IDs](#supported-agents)) |
| Installed workflow instructions | `rea doctor --skill --json` |
| Task | Readiness check |
| ----------------------------------- | -------------------------------------------------------------------------- |
| Static JavaScript/Electron analysis | Run `rea analyze-javascript-application PATH --json` directly. |
| One analysis engine | `rea doctor --provider ghidra --json` (or `hopper`, `ida`, `jadx`, `kuna`) |
| One agent registration | `rea doctor --client codex --json` (see [client IDs](#supported-agents)) |
| Installed workflow instructions | `rea doctor --skill --json` |
A scoped report has `scope.mode: "explicit"`. Its `scope_checks` determine
`healthy` and the exit status. Other checks appear in `informational_checks`;
@@ -654,6 +654,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
| Factory Droid | `droid` |
| Trae | `trae` |
| WorkBuddy | `workbuddy` |
| LM Studio | `lm_studio` |
| ZCode | `zcode` |
| DeepSeek Harness | `dsh` |
| FreeBuff | `freebuff` |
@@ -663,6 +664,8 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
`freebuff` requires `rea-agents@6.8.0` or later. Older releases reject
`rea setup --client freebuff` as an unsupported client.
`lm_studio` requires a release after 6.8.0.
See [client configuration and troubleshooting](client-configuration.md) for
configuration paths, profiles, trust policies, and schema-limit workarounds.
@@ -1160,7 +1163,11 @@ selected formula's `libexec/openjdk.jdk/Contents/Home`, for example
Java version bounds declared by your Ghidra installation; REA does not select
another JDK when `JAVA_HOME` is explicit or the PATH JVM runs. On macOS,
when the PATH launcher cannot start a JVM, it tries executable Homebrew
`opt/openjdk` candidates, preferring the host architecture's prefix. Failed
`opt/openjdk` and installed `opt/openjdk@<major>` candidates, preferring the host
architecture's prefix and the unversioned formula within each prefix. Versioned
aliases are considered in ascending major order within Ghidra's declared Java
bounds; the selected executable still undergoes the normal Java probe and
admission checks. REA does not invoke Homebrew or install a JDK. Failed
discovery probes remain available in installation diagnostics. Doctor distinguishes an absent Java
command from a command that runs and fails, and retains bounded probe output
and any parsed properties when the selected runtime cannot be admitted.
@@ -1422,6 +1429,20 @@ in the project root; when omitted it defaults to the selected target's filename.
Paths, globs and command flags are not program names. The matching `.rep`
directory must be present; restore `.grez` archives with Ghidra first.
For a `.gzf` packed program, first import it into a new Ghidra project in an
existing, caller-selected directory. A packed program is not a `.grez` project
archive:
```bash
"$GHIDRA_INSTALL_DIR/support/analyzeHeadless" "/absolute/new-project-directory" "new-project" \
-import "/absolute/program.gzf" -noanalysis
```
Select the resulting `new-project.gpr` with `REA_GHIDRA_PROJECT_PATH`. If the
imported program name differs from the selected target's filename, set
`REA_GHIDRA_PROGRAM_NAME` to the name reported by Ghidra's import. REA then
opens its own read-only project copy; it does not import the packed program.
```bash
export REA_ANALYSIS_PROVIDER=ghidra
export REA_GHIDRA_PROJECT_PATH=/absolute/projects/app-analysis.gpr
@@ -1443,18 +1464,31 @@ the original database; REA reads selected files only for fingerprinting and copy
This mode does not copy unrelated sibling projects, rerun repair scripts, or
admit bridge database edits. Cleanup removes the owned copy.
The bridge checks the program's recorded import SHA-256 against the admitted
target digest; mismatches and project changes fail explicitly. Reopen the target
to resolve a new profile after intentionally changing the source project.
target digest. An unavailable or malformed recorded digest is reported as an
unverified identity, distinct from a known mismatch; neither admits the program
or triggers a fresh import. Select a matching target or explicitly configure a
different project or program. Changes to the source project also fail admission;
reopen the target to resolve a new profile after intentionally changing it.
The profile records the caller path, resolved project/name, program and content
fingerprint. Original analysis settings and completeness remain **unknown**;
prior annotations or edits are caller-project state, not fresh REA discoveries.
For supported header-selected MIPS ELF targets, the profile also binds the
selected target's ELF metadata; it does not claim a fresh-import MIPS lane was
used for the saved project.
A matching recorded import digest does not claim that the database is unedited.
Saved REA seed/analyzer reports remain historical facts in retained source Evidence;
they are not interpreted as overrides applied by this session.
Project content participates in snapshot identity, so different project states
cannot reuse the same profile's cached observations.
If a function selector is missing, use `list_procedures` or `search_procedures`
to inspect the selected project's observed inventory. A missing entry does not
prove the function is absent from the executable. To explicitly compare with a
fresh import, start another REA process without `REA_GHIDRA_PROJECT_PATH` and
`REA_GHIDRA_PROGRAM_NAME`; the fresh analysis will not include the selected
project's annotations.
To prepare a new project explicitly, use your existing Ghidra installation in a
new directory you own (this writes that project; REA does not save it for you):
+1 -1
View File
@@ -94,7 +94,7 @@ The assembly label index is scanned once per export reader. Cancelling a request
Regex procedure and string search uses ECMAScript Unicode matching in a cancellable worker with a matching deadline and a worker old-generation heap. Literal matching remains the default. Cancellation or a matching deadline leaves the session available. The deadline is `REA_REGEX_MATCHING_DEADLINE_MS`, a positive decimal integer from 1 to 2147483647 ms. Unset uses 5000 ms. A larger value is refused because Node schedules a delay past that signed 32-bit bound as 1 ms. Invalid values are configuration errors and do not fall back to the default. The setting is read from the environment that starts REA, so a change needs a restart. A deadline failure names the effective milliseconds, whether they came from the default or the setting, the default, and the maximum. The worker old-generation heap does not raise it. The worker heap request is `REA_REGEX_WORKER_HEAP_REQUEST_MIB`, a decimal integer from 7 to 16384 MiB. Unset uses 128 MiB. A request below 7 MiB aborts the worker during startup, and a larger value is refused. Invalid values are configuration errors and do not fall back to 128. The setting is read from the environment that starts REA, so a change needs a restart. A heap failure names the effective request, whether it came from the default or the setting, the default, the floor, and the maximum. A positive Node.js `--max-old-space-size` on the process that starts REA, including `NODE_OPTIONS`, overrides that request and sets the effective heap. `--max-old-space-size-percentage` takes precedence over that flag in either order. REA does not turn the percentage into a MiB, so the effective heap stays unestablished instead of the worker heap request or the absolute flag. The reported `NODE_OPTIONS` value is the process initial environment when that block can be read. A later edit to the environment is not the value Node applied, and an unreadable initial value leaves the effective heap unestablished. The launch Node options are the initial command line on Linux when that buffer still has a program entry, not a later edit to `process.execArgv`. A preload can clear that array after V8 has applied the flags. Setting `process.title` or `--title` overwrites the command line, so a buffer without a program entry leaves the effective heap unestablished. `--env-file` or `--env-file-if-exists` can supply `NODE_OPTIONS` when the initial environment has none. That file is not part of the initial environment block, so the effective heap stays unestablished instead of the worker heap request. An initial `NODE_OPTIONS` is still used, because Node does not let the file replace it. A positive `--max-heap-size`, including the underscore form and launch `NODE_OPTIONS` on Node versions that allow it, sets the total heap rather than the old generation and can be lower than the worker heap request. The effective old generation stays unestablished while that value is in effect. A zero or empty value clears it. `--experimental-config-file` or `--experimental-default-config-file` can set `nodeOptions`, including a heap percentage, from a file that is not read back. The effective heap stays unestablished when either flag is present, including when an absolute `--max-old-space-size` is also on the command line or in the recovered `NODE_OPTIONS`. Node accepts the config path as `=` or the next argument.
Single-function subprocess output and each cached source facet share a 16 MiB buffered-text capacity. Cached spans are checked before allocation; larger text remains in the local export and the error identifies the file and range. `analyze_function` with `detail: "complete"` (the CLI default) returns the complete dossier inline, which can still be large; later retained views do not remove that first transfer. The MCP default retains the dossier and returns its summary, so page the facets you need through `inspect_analysis_view`, or prefer the individual text tools for a focused question.
Single-function subprocess output and each cached source facet share a 16 MiB buffered-text capacity. Cached spans are checked before allocation; larger project-export spans remain in the local export and the error identifies the file and range. A single-function run does not by itself retain a complete project export. `analyze_function` with `detail: "complete"` (the CLI default) returns the complete dossier inline, which can still be large; later retained views do not remove that first transfer. The MCP default retains the dossier and returns its summary, so page the facets you need through `inspect_analysis_view`, or prefer the individual text tools for a focused question.
## Mach-O data entries
+107 -8
View File
@@ -218,6 +218,12 @@ Malformed provider replies return `unreadable_output` for
`resolve_containing_procedure` before successful-result projection;
cancellation and incomplete cleanup remain errors.
For a procedure match, `procedure_references` with `direction: "incoming"` can
provide the selected provider's reference kinds when supported. Its scope covers
the procedure body, so compare target addresses when selecting references to the
exact entry. It is a separate observation and does not establish runtime use or
complete reference discovery.
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 omit `structuredContent` from the
@@ -278,6 +284,17 @@ to `/absolute/view-request.json`, then run
`rea inspect-analysis-view /absolute/view-request.json --json`. This also reads
pre-summary source records without changing their identity; no live provider is started.
Unavailable or inapplicable native collections keep `items.rows: null` and
report `coverage: {"status":"unavailable","examined":0,"total":null,"next_offset":null,"exhausted":false}`.
This applies to unrecorded `native-load-image` checks/sections and inapplicable
`native-context` result limitations. Their total is unknown; no continuation is
offered. Stop paging that collection and inspect the returned unknowns rather
than treating it as an observed empty set or retrying a null offset. Recorded
empty collections, empty selections and offsets past the end retain numeric
totals and exhausted page coverage. Clients upgrading from the earlier
zero-total representation must handle the new `unavailable` status and nullable
total; historical saved views remain valid.
`procedure_address`, `procedure_callees`, `procedure_callers`,
`resolve_containing_procedure` and `get_call_graph` also accept `detail: "summary"`
(complete remains the default). The summary includes the first 64 recorded
@@ -379,6 +396,12 @@ is null. `total` counts all matching heads; each page preserves complete records
in stable unknown ID order. Filters apply before paging. Pages reflect current
session state, so keep the unknown ledger unchanged while traversing it.
`list_unknowns` only lists registered heads. Import restores the Unknown records
present in a bundle; it does not turn residual question text embedded in Evidence
into registry entries. An empty registry therefore does not establish that
retained Evidence has no open questions. Inspect the relevant Evidence or its
analysis view for its residual questions.
Automatically retained workflow questions include their immutable result
Evidence ID. Distinct results retain independent questions and source links;
repeating the same result does not create another unknown. Workflow Evidence
@@ -388,6 +411,19 @@ Duplicate creation is a nonretryable `invalid_request`; use the existing head
or update it with its current `expected_revision`. A stale update remains a
retryable `revision_conflict`.
If automatic question publication fails after a workflow completes,
`details.workflow_unknown_publication` identifies the Evidence and states
whether its retention was confirmed. `created_questions` and
`existing_questions` count processed question occurrences; duplicate questions
may share an Unknown identity. `total_questions` counts string questions, and
`next_question_index` is the zero-based position in the workflow's original
`residual_unknowns` array where publication stopped. It is not a resume token.
For a retained record on the same MCP connection, retrieve the Evidence by ID
with `get_evidence_bundle` and inspect committed heads with `list_unknowns`.
Unprocessed questions remain in the completed Evidence; they were not checked
by this attempt and may already exist from earlier work. A one-shot invocation identifies session-only retention
as `invocation-only` and carries the completed Evidence in its failure when available.
When `record_unknown` or `update_unknown` commits but its complete reply exceeds
the response budget, `details.reported_limits.committed_unknown` identifies the
exact unknown ID, committed revision and mutation Evidence IDs. Its retained
@@ -502,10 +538,12 @@ result's `_meta`, whether the call succeeded or failed:
```
The marker is declared once per tool contract (`returnsTargetContent`) and is
on by default: `export_evidence_bundle`, `record_unknown`, `search_guidance`,
and `read_guidance_section` omit it. The first two contain only REA session
state or caller-supplied values; the guidance tools return REA's own installed
documentation, not text from an analysis target. Tools whose results or errors
on by default: `inspect_analysis_settings`, `update_analysis_settings`,
`export_evidence_bundle`, `record_unknown`, `guided_workflows`,
`search_guidance`, and `read_guidance_section` omit it. The first four contain
only REA session state or caller-supplied values; the workflow and guidance
tools return REA's own installed workflows and documentation, not text from an
analysis target. Tools whose results or errors
can include provider, launcher, or device output stay marked, including
`binary_session` (provider failure
reasons and availability diagnostics), `close_binary` (provider cleanup
@@ -658,12 +696,19 @@ refuses the summary before analysis. Further views use the returned
`parent_evidence_id` through `inspect_analysis_view`, or a saved Evidence file or
portable bundle, and never repeat decompilation.
A null provider observation produces `status: "unavailable"` and
`reason: "no_pseudocode"`; item limitations retain any provider explanation.
This does not establish why pseudocode is absent or whether a retry would help.
Operation failures and malformed non-null output remain `status: "error"`.
The `failed` count includes both unavailable and error items. Existing retained
records keep their original item statuses and error details.
A `batch-decompile` view selects one `collection` and repeats the batch counts
and provider identities:
- `items`: rows in retained order with their original `index`, so duplicate
selectors stay distinct. A row keeps the selector in `address`, its `procedure`
identity and `status`, plus `pseudocode_code_units` or its exact `error` and
identity and `status`, plus `pseudocode_code_units`, an unavailable `reason`, or its exact `error` and
`limitations`. `where.indices`, `where.addresses`, `where.procedure_addresses`
and `where.statuses` filter exactly and combine; an item with unknown identity
never matches `procedure_addresses`. Omitted filters select all, and empty
@@ -672,11 +717,11 @@ and provider identities:
- `pseudocode`: one `index`'s text in UTF-16 code-unit pages. `offset` and `limit`
default to 0 and 16,384, and `coverage` measures that body. An offset inside a
surrogate pair is refused, and a limit ending inside one stops before it. An
error item has no text and is refused with a pointer to `items`.
unavailable or error item has no text and is refused with a pointer to `items`.
Page `coverage` counts the selection only: exhausting it establishes nothing
about functions outside the batch. Evidence `limitations` aggregate every item's
provider observations and are not attributed to one successful item; only error
provider observations and are not attributed to one successful item; unavailable and error
items keep their own. A page over the response budget offers a smaller selection
from the same `source`: fewer rows, or one body's `pseudocode` pages when a single
row does not fit. An inline source offers none, because the record cannot be
@@ -946,6 +991,15 @@ concrete version, selection source, and complete analysis profile through
choices return typed selection details. A selected provider is never replaced
automatically after a runtime failure.
When an admitted `open_binary` attempt fails, `error.details.selection_at_failure`
records the canonical binary path and SHA-256, resource root (including its
caller-selected path), or absence of selection at
the end of that serialized attempt. This is a past observation: a queued or
later operation may already have changed the selection. No selection does not
establish that cleanup is complete. Call `binary_session` for current state
and cleanup status. Validation failures that do not enter the session have no
such receipt. The original error and any cleanup recovery remain authoritative.
`open_binary` resolves an Apple bundle directory (`.app`, `.appex`,
`.framework`, `.xpc`, `.systemextension`, `.bundle` or `.plugin`, compared
case-insensitively) to the program file its `Info.plist` names in
@@ -1564,6 +1618,49 @@ support for omitted historical values.
known and differ. If only one value is known, storage comparison is unknown;
when both are omitted, storage is not compared.
<!-- BEGIN:compare-artifacts:compared-format-metadata-kinds -->
`compare_artifacts` also compares the declarations in the `format_metadata`
of UnityFS members (`unityfs-entry`), PyInstaller entries
(`pyinstaller-entry`), Node SEA members (`node-sea-entry`),
ESP image segments (`esp-image-segment`), DfuSe elements (`dfuse-element`),
ESP flash partitions (`esp-flash-partition`) and FIT images (`fit-image`);
each format's guide names the compared fields. A changed declaration is a
metadata change, including when content is unavailable. A declaration that
only one side records, or that the other side records as another kind, is
unknown and adds a limitation. A field that either side could not read is
unknown, and the other fields still compare. Other kinds are not compared:
their fields record layout, come from the bytes of the artifact or its
container or from caller options that identify the target, or repeat
occurrence fields, except Godot PCK source paths, which
[Godot PCK archives](godot-pck.md) explains.
<!-- END:compare-artifacts:compared-format-metadata-kinds -->
Every occurrence records `entry_kind`, `executable` and `encrypted`. A
different entry kind, `encrypted` value or occurrence limitation text is a
`metadata` difference, and a different `executable` value is an `executable`
difference. A symlink's own permission bits never mark it `executable`,
because running a symlink runs its target. When either side's content is
unavailable, `compare_artifacts` lists the differences that it can still
observe with `availability`, and the path compares `unknown` unless a
declaration changed. REA does not read symlink targets or encrypted bytes,
so a non-executable file that becomes a symlink compares `unknown` in
`metadata` and `availability`, and so does a member that becomes encrypted.
Declared sizes do not count without content: REA compares observed sizes.
For a node-less metadata record with a compared declaration, only that
declaration, these fields and any recorded ASAR storage decide the
comparison, so a record that becomes encrypted compares `changed` in
`metadata`. Such a record adds `availability` only when a declaration is
unknown. REA does not compare the record's unread payload or its
limitations, which describe the observation.
When either side has no content, `compare_artifacts` also compares the
occurrences' `declared_integrity` claims, which Godot PCK and ASAR readers
record. Claims of one algorithm that differ add `metadata`. REA cannot verify
them, so they do not make the path `changed`, and equal claims add nothing.
With content on both sides, the observed bytes decide.
POSIX letter-colon names such as `a:b` remain valid logical
names; Windows extraction refuses names its filesystem cannot materialize.
@@ -1707,7 +1804,9 @@ JSON Schema:
- Annotation prose leaves the advertised form: nested property descriptions,
literal examples, defaults, and titles are dropped. The root keeps its
description because it states the accepted input groups.
description because it states the accepted input groups. A root `detail`
property keeps its description because it selects between complete Evidence
and a bounded summary.
- Schema-local references are inlined within the budget. Kimi Code expands
references before sending provider requests, so sharing definitions cannot
establish the provider-facing size. Expansion stops at the budget before
+42 -4
View File
@@ -227,7 +227,17 @@ caveats. Failure details retain at most 32 rows within 2,048 JSON-encoded
UTF-8 bytes; names may be omitted to keep complete addresses and reasons. The
index retains every failure and its complete observed name.
Each function's `decompile-index.json` record, whatever its status, keeps the
complete limitations of the observations it used. The returned Evidence keeps
complete limitations of the observations it used.
When available, `provider_source` records the source of that attempt's
pseudocode observation: a file path, SHA-256 and byte range, or a
single-function decompile. The latter describes the method, not a unique run. A file
descriptor records the source as observed; the file may since have changed or
been removed, so compare its current digest before using it. The descriptor
does not authenticate the journal. `status: "unknown"` distinguishes
an unavailable source from an existing matching output whose original producer
is unknown. Older journal records can omit this field; omission is unknown.
Failed attempts retain their source in the append-only journal when the
provider supplied one, even if a later attempt succeeds. The returned Evidence keeps
at most eight first-observed distinct per-function provider limitations within an 8,000-byte JSON-encoded UTF-8 text budget and counts omitted observations. Repeated omitted text is counted per observation, not as a distinct caveat. This summary does not retain every omitted string in memory. A fresh output directory must not already exist. `--resume`
continues an export for the same target SHA-256, provider, analysis profile,
and layout; it requires the directory to exist, and an export without a known
@@ -300,6 +310,20 @@ relationships but reports their completeness as unknown. In particular, a resolv
tail call observed in one direction does not prove that the reverse inventory is
complete. An empty Hopper inventory cannot establish that no such call exists.
No whole-binary reverse scan is performed.
For large native traces, `trace_feature` accepts `detail: "summary"`. It retains
the complete trace and returns counts and its parent Evidence ID. Use
`inspect_analysis_view` with that ID and `view: {kind: "native-feature-trace",
collection: "references", offset: 0, limit: 64}` to page the original rows;
`matches` and `unknowns` are separate collections. Matches accept an exact
`address`; references accept exact `source_address` and `target_address`
filters. Row indices refer to the retained collection, and page coverage does not establish provider discovery
completeness. `list_unknowns` pages the independently managed question heads.
In the one-shot CLI, `rea trace ... --detail summary --evidence_output PATH`
stores the trace and its linked source Evidence for later
`inspect-analysis-view` calls. This bundle does not include the CLI session's
Unknown registry; its original question texts remain available in the trace's
`unknowns` pages.
Regex searches use ECMAScript Unicode syntax in a cancellable worker with a
matching deadline and a worker old-generation heap. Deadline or cancellation
stops matching while leaving the Hopper API available. The deadline is
@@ -434,6 +458,18 @@ nodes and edges. Node payloads are bounded to 8 MiB and serialized results to
32 MiB. Pagination returns edges whose source is on the node page and preserves
stable IDs for endpoints outside that page.
Procedure, node and edge `evidence_id` values identify the source function
observations used by the trace. In an MCP session, retrieve them from the
Evidence bundle for inspection or export. A replayed source keeps the original
observation's identity; the trace also reports that replay occurred. Older
cached value traces are not replayed under the revised workflow profile, while
their historical Evidence remains available.
The one-shot CLI accepts `--evidence_output <new-path>` to save the trace and
the transitive source Evidence closure named by its links. The destination is
never overwritten. `--detail summary` requires `--evidence_output`; the bundle
still retains the complete trace and source Evidence.
Within one value trace, each exact procedure selector is attempted at most once,
including failed analyses. A failed seed returns the provider's original typed
failure and corrective guidance; it cannot establish a trace or target identity.
@@ -444,8 +480,10 @@ resources it owns stops the trace: the result is that provider's own
`cleanup_incomplete` failure with its resources and any bound cleanup retry, and the work
admitted before the stop is retained as partial Evidence with a located unknown
at the stopping read. A successful seed without a native def-use
model still returns its procedure and an explicit coverage unknown. A new trace
can retry failed selectors.
model still returns its procedure and an explicit coverage unknown. An unrecorded
facet stays unknown; when the provider explicitly reports unavailability, the trace
retains its reason. REA's Kuna provider explicitly reports that it does not supply
native def-use observations. A new trace can retry failed selectors.
Paging and traversal completeness are separate facts. `page_truncated` means the
page omits retained nodes, and the edges they source, before `offset` or after
@@ -1399,7 +1437,7 @@ procedures. The CLI equivalent is
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
uninitialized or excluded memory. See
[Ghidra byte search](ghidra-provider.md#masked-byte-search) for mask and coverage
semantics. This query never executes or modifies target bytes.
+6 -2
View File
@@ -82,8 +82,12 @@ override. Asset and argument decoding, padding scans, and inventory construction
yield in bounded batches and honor caller cancellation. Asset count and name
budgets bound retained metadata independently of stored member body sizes. The PE
name probe reads headers from the first 64 KiB of the carrier; a PE whose
headers extend beyond that is left to native classification. Snapshot and
code-cache bytes remain opaque.
headers extend beyond that is left to native classification. ELF note tables
are read for at most one note per 12 carrier bytes in total, the most that
disjoint tables can hold, so program headers that repeat the same notes do not
rescan them once per header. Past that count the remaining tables are
unreadable: an ELF without a confirmed blob keeps its native classification,
and one with a blob fails closed. Snapshot and code-cache bytes remain opaque.
## Verification
+7 -3
View File
@@ -358,9 +358,13 @@ sibling filename such as `tool-helper`; selected parent directories still
normalize within descendant paths, including when the working directory or an
observation path is spelled with a trailing `/` or `/.`. A selected filesystem
root `/` also aliases descendant paths, without rewriting URL separators or
HTML closing tags. The comparison contract
records these rules as `path-component-boundaries-v3`, so captures made with the
earlier rules cannot be compared as if the text had the same meaning.
HTML closing tags. When no temporary root applies to an observation, no temporary-root
path is selected: literal `<no-temporary-root>` input and sampled command text
remain unchanged. The comparison contract
records these rules as `path-component-boundaries-v4`, so captures made with the
earlier rules cannot be compared as if the text had the same meaning. Historical
captures remain readable; recapture both sides with the same path normalization
version before comparing them.
Rendered frame `lines` hold the visible rows after normalization, with trailing
U+0020 spaces removed. Blank rows and internal spaces remain. `columns` records
+11 -8
View File
@@ -77,14 +77,17 @@ table index; original names remain available in the supplied artifact.
Path failures use bounded escaped previews and report the original length;
accepted paths and retained source names are not shortened.
`compare_artifacts` compares these node-less records as declarations using the
container, container name, source name and type code. Equal option or dependency
declarations can therefore compare unchanged without implying that an external
dependency's bytes were found. For materialized PyInstaller members, the same
fields participate in the metadata delta independently of content identity;
archive offsets and table positions remain location facts, not declaration
identity. Missing declaration facts and unavailable non-metadata content remain
unknown.
`compare_artifacts` compares node-less records, namely runtime options,
dependencies and namespace packages, as declarations using the container,
container name, source name and type code, and whether the record is
encrypted; their limitations are not compared. Equal declarations can
therefore compare unchanged without implying that an external dependency's
bytes were found or that unread legacy namespace payloads match. For
materialized PyInstaller members, the same fields participate in the metadata
delta independently of content identity, including when the member's bytes
are unavailable; archive offsets and table positions remain location facts,
not declaration identity. Missing declaration facts remain unknown, as does
unavailable non-metadata content whose declaration is unchanged.
Request `detail: "summary"` for a compact inspection, then query retained
Evidence with `inspect_analysis_view`. An inventory view can select
+9 -3
View File
@@ -14,7 +14,13 @@ can run.
## GNU objdump
`inspect_with_objdump` and `rea inspect-with-objdump` capture stdout and stderr as inline Evidence. Artifact inspection Evidence records SHA-256 digests before and after the provider call and whether those observed digests differ; this cannot prove the exact bytes consumed if the artifact changes during execution. Choose one artifact operation: `file_headers`, `section_headers`, `symbols`, `relocations`, `dwarf`, `disassemble`, or `disassemble_all`. `disassemble` maps to `-d`; `disassemble_all` maps to `-D` and has different symbol-boundary semantics. The pathless `inspect_with_objdump {"operation":"architectures"}` MCP call or `rea list-objdump-architectures` CLI command maps to `-i` and produces subjectless provider-capability Evidence describing BFD targets compiled into the installed binary, not a guarantee that a target is supported.
REA sends GNU-style arguments to the selected command but does not verify its
implementation or version. Evidence identifies the command profile as
`objdump.command` and retains the selected executable; it does not claim that
the executable is GNU objdump. Other implementations may accept some commands
while differing in supported options and output.
`inspect_with_objdump` and `rea inspect-with-objdump` capture stdout and stderr as inline Evidence. Artifact inspection Evidence records SHA-256 digests before and after the provider call and whether those observed digests differ; this cannot prove the exact bytes consumed if the artifact changes during execution. Choose one artifact operation: `file_headers`, `section_headers`, `symbols`, `relocations`, `dwarf`, `disassemble`, or `disassemble_all`. `disassemble` maps to `-d`; `disassemble_all` maps to `-D` and has different symbol-boundary semantics. The pathless `inspect_with_objdump {"operation":"architectures"}` MCP call or `rea list-objdump-architectures` CLI command maps to `-i` and produces subjectless Evidence containing the selected command’s output, not a guarantee that any target is supported. GNU objdump uses this option to list its compiled-in BFD targets.
Failure to launch the selected executable reports provider unavailability, the
configured command and the host error under `boundary: "provider-launch"`.
@@ -25,7 +31,7 @@ their rejected fields in input issues before artifact reads or provider launch.
For DWARF, REA does not follow local debug links by default. Set `follow_debug_links` to `true` to pass `-WK`; external debug-file identities are not bound to Evidence when following links is enabled. The CLI flag is `--follow-debug-links`; `--no-follow-debug-links` or `--follow-debug-links=false` keeps the default. A separate `true` or `false` word after the flag is not its value. REA clears `DEBUGINFOD_URLS` in the objdump child environment to disable debuginfod network access on GNU binutils versions that do not support `-WE`. The complete raw output is authoritative; REA does not normalize it into a version-independent disassembly schema.
For a known native symbol, use `operation: "disassemble", symbol: "my_function"` in MCP or `--symbol my_function` in CLI. REA passes `--disassemble=my_function` to objdump before collecting output, rather than producing the whole disassembly and filtering it afterward. Omit `symbol` to keep full code-section disassembly. Other operations reject this selector. Use a nonempty, NUL-free name with well-formed Unicode; unsupported values are rejected before reading the artifact or starting objdump. Symbol names are passed unchanged; matching, versioned symbols, aliases and the end of the disassembly follow the installed GNU objdump's semantics. Evidence records the selector and actual arguments. An empty match does not establish artifact-wide absence, and the selected observation does not contain the omitted disassembly; request the complete operation when needed.
For a known native symbol, use `operation: "disassemble", symbol: "my_function"` in MCP or `--symbol my_function` in CLI. REA passes `--disassemble=my_function` to objdump before collecting output, rather than producing the whole disassembly and filtering it afterward. Omit `symbol` to keep full code-section disassembly. Other operations reject this selector. Use a nonempty, NUL-free name with well-formed Unicode; unsupported values are rejected before reading the artifact or starting objdump. Symbol names are passed unchanged; matching, versioned symbols, aliases and the end of the disassembly follow the selected executable's semantics. Evidence records the selector and actual arguments. An empty match does not establish artifact-wide absence, and the selected observation does not contain the omitted disassembly; request the complete operation when needed.
```sh
rea inspect-with-objdump ./program disassemble
@@ -40,7 +46,7 @@ Captured stdout and stderr are retained up to a 16 MiB combined limit; `output_t
## GDB
Persistent GDB sessions, including the MCP workflow and one-shot `rea debug-with-gdb` CLI, are available only on Linux. REA verifies a live inferior's output descriptors through `/proc` before detaching it; it does not claim equivalent descriptor verification on macOS. Windows is also unsupported because owned Job Object cleanup can terminate debugger inferiors. On supported Linux hosts, `start_gdb_session`, `gdb_console`, `gdb_session_status`, and `close_gdb_session` manage a persistent GDB/MI session in MCP. The one-shot CLI requires a caller-selected durable TTY: `rea debug-with-gdb --inferior-tty /dev/pts/7 'file ./program'`. The TTY must remain available after GDB exits so a detached inferior can continue writing. MCP starts GDB with `--nx --quiet --interpreter=mi3`, retries initialization with MI2 if MI3 initialization returns an error and cleanup succeeds, waits for its prompt, and disables auto-loading. Pass optional `inferior_tty` to select the terminal that future inferior runs use; REA configures it before the session becomes ready and checks the exact value with GDB/MI. `gdb_session_status` reports the selected path, whether REA still considers the configuration verified, and whether invalid UTF-8 has been observed in MI bytes (`mi_invalid_utf8`) or stderr (`stderr_invalid_utf8`). Commands that can change the inferior TTY or redirect a run revoke that verification permanently for the session, so close retains GDB ownership if a live inferior remains. The complete built-in inspections `show inferior-tty` and `help inferior-tty` preserve verification; compound commands still use the conservative mutation checks. Closing a session with one live inferior and a verified TTY automatically detaches the target, which can resume execution; MCP clients receive destructive-operation annotations for this behavior. The console remains unrestricted and can run shell commands, change target state, access files, and use network in the REA process user's security context. Raw MI records and console/target/log streams are returned with Evidence; command output is retained up to 16 MiB and explicitly marked when truncated. GDB startup waits 10000 ms and each MI command waits 120000 ms. This release has no runtime override for those deadlines, the 16 MiB command output budget, session capacity, or the pending-command queue. `command_timeout_ms` does not change them.
Persistent GDB sessions, including the MCP workflow and one-shot `rea debug-with-gdb` CLI, are available only on Linux. REA verifies a live inferior's output descriptors through `/proc` before detaching it; it does not claim equivalent descriptor verification on macOS. Windows is also unsupported because owned Job Object cleanup can terminate debugger inferiors. On supported Linux hosts, `start_gdb_session`, `gdb_console`, `gdb_session_status`, and `close_gdb_session` manage a persistent GDB/MI session in MCP. The one-shot CLI requires a caller-selected durable TTY: `rea debug-with-gdb --inferior-tty /dev/pts/7 'file ./program'`. The TTY must remain available after GDB exits so a detached inferior can continue writing. MCP starts GDB with `--nx --quiet --interpreter=mi3`, retries initialization with MI2 if MI3 initialization returns an error and cleanup succeeds, waits for its prompt, and disables auto-loading. Pass optional `inferior_tty` to select the terminal that future inferior runs use; REA configures it before the session becomes ready and checks the exact value with GDB/MI. `gdb_session_status` reports the selected path, whether REA still considers the configuration verified, and whether invalid UTF-8 has been observed in MI bytes (`mi_invalid_utf8`) or stderr (`stderr_invalid_utf8`). Commands that can change the inferior TTY or redirect a run revoke that verification permanently for the session, so close retains GDB ownership if a live inferior remains. The complete built-in inspections `show inferior-tty` and `help inferior-tty` preserve verification; compound commands still use the conservative mutation checks. Closing a session with one live inferior and a verified TTY automatically detaches the target, which can resume execution; MCP clients receive destructive-operation annotations for this behavior. The console remains unrestricted and can run shell commands, change target state, access files, and use network in the REA process user's security context. Raw MI records and console/target/log streams are returned with Evidence; command output is retained up to 16 MiB and explicitly marked when truncated. A single console, target or log record over the 16 MiB MI line limit is dropped and the command is marked truncated, while an oversized result or async record still stops the session. GDB startup waits 10000 ms and each MI command waits 120000 ms. This release has no runtime override for those deadlines, the 16 MiB command output budget, session capacity, or the pending-command queue. `command_timeout_ms` does not change them.
With multiple live inferiors, or a live inferior without a verified TTY, manual
close requests an explicit disposition and keeps the session ready. Use
+3 -1
View File
@@ -10,7 +10,9 @@ strings, declared size, dataflags, blocks-and-directory table bounds, the
producer's 16-byte data hash as declared, and the resolved data-area offset.
Members (`CAB-*` SerializedFiles, `.resS` resource streams, shared assets)
retain declared source paths, virtual stream offsets, sizes and raw node flags.
The directory flag identifies directories. Virtual offsets are not physical
The directory flag identifies directories. `compare_artifacts` compares each
member's declared `source_path` and raw `flags`; its index and offset are
layout. Virtual offsets are not physical
carrier locations: member `source_location` is null. Member reads only examine
their covering blocks. Uncompressed ranges stream directly; compressed ranges
reuse at most one completed decoded block. Independent 64 KiB output chunks
+4 -2
View File
@@ -86,8 +86,10 @@ and import map have 32 MiB, 16 MiB and 4 MiB input limits. Native resolution has
one 20-second deadline; cleanup may extend that deadline. One renderer is
requested, and there is no aggregate host memory-limit claim. Cancellation
closes only the owned browser; cleanup failure cannot return a successful trace.
`REA_BROWSER_NO_SANDBOX=true` passes Chromium's corresponding launch option when
the host requires it; it does not enable an REA security boundary.
Chromium keeps its sandbox, except under Termux on Android, which always
launches it without one. On hosts that cannot provide one, such as root in a
container, `REA_BROWSER_NO_SANDBOX=true` launches it without the sandbox; the
variable does not enable an REA security boundary.
See [testing](testing.md) for the native loading oracle and CLI/MCP real lane.
+84
View File
@@ -0,0 +1,84 @@
# WeChat mini-program wxapkg archives
`inspect-artifact` and `extract-artifact` read plaintext WeChat mini-program
package (`.wxapkg`) containers without running WeChat or an unpacker. The
plaintext form starts with a `0xBE` mark and an 18-byte big-endian header:
an unknown `info` field producers leave zero, the member-table byte length,
the member-data byte length, a `0xED` mark and the member count. Producers
either count only the name/offset/size rows in that table length or include
the four-byte member count as well; both declarations are accepted. The table
region then carries one name/offset/size row per member; member bytes are
stored verbatim at absolute file offsets.
Inspection retains the header facts: `info`, index and data lengths, member
count and directory status. Members retain declared source paths, absolute
carrier offsets and declared sizes, so `source_location` reports real file
positions. Member reads only examine their declared range in bounded 64 KiB
chunks. Carrier freshness is checked when a member is opened and after its
stream completes. Reader close cancels and joins outstanding streams before
releasing its descriptor.
Encrypted PC-client packages start with the `V1MMWX` magic instead of the
plain header. They keep their format identity but report `encrypted`
directory status with unknown coverage: member identities and bytes remain
unknown and no decryption is attempted, so extraction refuses with a typed
error rather than producing ciphertext. Declared tables or data regions
that exceed the file, member extents outside the declared data region,
truncated rows, invalid UTF-8 names, and counts that cannot fit their table
are malformed-input errors.
## Resource bounds
These are resource policies, not format maxima. The encoded member table is
limited to 16 MiB and its buffer, parsed rows, retained entries and escaped
path projections share a conservative 64 MiB admission budget, reserved
before allocation; a declared member name is limited to 64 KiB. A reader
admits four active member streams. The shared archive plaintext-work ceiling
is the larger of 256 MiB and 500 times carrier size: every declared member
extent is charged, including aliases that overlap one stored range. A
separate member-I/O budget allows four times the larger of 1,024 and the sum
of the member count and declared 64 KiB plaintext chunks, bounding repeated
tiny-member reads independently of output bytes.
## CLI
```sh
rea inspect-artifact app.wxapkg --detail summary \
--evidence_output evidence/package.json --json
rea extract-artifact app.wxapkg --json
```
Detection is byte-based on the `0xBE`/`0xED` marks or the `V1MMWX` magic, so
`.wxapkg`, `__APP__.wxapkg` and extensionless cache files all classify as
`wxapkg` containers. Compact delivery keeps complete Evidence in the
requested bundle.
## Verification scope
Synthetic fixtures cover plaintext headers, multiple members, empty members,
chunked reads, unicode and dotted names, malformed marks, truncated tables,
invalid UTF-8, table and directory-budget admission, member extents inside
the header, the index, or beyond the declared data region, a data region
declared past the file, a one-byte member whose table length includes the
member count, an overstated first row consuming its successor, V1MMWX
packages, cancellation inside member reads, paused iteration, close during
I/O, cleanup retry, stream capacity and carrier freshness. Compiled
full/compact MCP tests cover inventory and extraction.
Real producer verification used plaintext `0xBE` packages from a local macOS
WeChat mini-program cache (`__APP__.wxapkg` main packages and
`__PLUGINCODE__.wxapkg` plugin packages across three distinct app
directories): every member extracted byte-identical to its declared carrier
range, including `__plugin__/`-namespaced members and the producer's
root-prefixed (`/name`) member convention. A cached `V1MMWX` PC package
reports the encrypted directory state with unknown member facts and refuses
extraction.
`wxml`/`wxss` source recovery, compiled bundle semantics, subpackage
relationships and runtime behavior are outside this container reader's
verification scope.
Artifact comparison pairs members by their package-relative paths and
compares their bytes. It does not compare `wxapkg` or `wxapkg-entry`
metadata: header fields repeat the package bytes, `index` is layout, and
`source_path` keeps a producer root prefix that the occurrence path strips.
+35 -17
View File
@@ -1,18 +1,23 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../../dist/domain/evidence.js";
import { webSourceLocationResultSchema } from "../../dist/domain/webSourceLocation.js";
import { PrivateRuntimeRoot } from "../../dist/process/PrivateRuntimeRoot.js";
import { startBrowserSourceMapSite } from "../fixtures/browser-source-map-site.mjs";
import { mcpTextValue } from "./mcp-verifier-results.mjs";
import { withVerifierCleanup } from "./verifier-cleanup.mjs";
import { createPackageVerifierCommands } from "./verify-package-commands.mjs";
import { importVerifierSource } from "./verifier-source-loader.mjs";
const { BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS } = await importVerifierSource(
new URL("./browser-process-startup.mts", import.meta.url).href,
import.meta.url,
);
/** Verify real Chrome capture → export → original compiler point through packaged CLI/MCP. */
export async function verifyBrowserSourceMap(
@@ -21,21 +26,35 @@ export async function verifyBrowserSourceMap(
) {
let site;
let root;
let commands;
let transport;
let client;
const closeResource = async (owner, resource) => {
if (owner === undefined) return;
try {
await owner.close();
} catch (cause) {
commands?.adopt(owner, resource);
throw cause;
}
};
return withVerifierCleanup(async () => {
site = await startBrowserSourceMapSite();
root = await mkdtemp(join(tmpdir(), "rea-browser-source-map-"));
root = await PrivateRuntimeRoot.create({
prefix: "rea-browser-source-map-",
});
commands = await createPackageVerifierCommands({ workspace: root });
const env = {
...process.env,
REA_LOG_LEVEL: "silent",
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-provider/hopper",
};
const cli = async (...args) => {
const { stdout } = await promisify(execFile)(
const { stdout } = await commands.exec(
process.execPath,
[entrypoint, ...args, "--json"],
{ env, timeout: 45000, maxBuffer: 16 * 1024 * 1024 },
// Allow the shared cold-start budget plus capture, cleanup and delivery.
{ env, timeout: 120_000, maxBuffer: 16 * 1024 * 1024 },
);
return parseEvidence(JSON.parse(stdout));
};
@@ -50,6 +69,7 @@ export async function verifyBrowserSourceMap(
"capture-browser-scenario",
JSON.stringify({
browser: { mode: "launch", executable_path: executable },
command_timeout_ms: BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
start_url: { url: site.origin },
actions: [
{
@@ -66,12 +86,12 @@ export async function verifyBrowserSourceMap(
capture: { network: { response_body: true } },
}),
);
const capturePath = join(root, "capture.json");
const capturePath = join(root.path, "capture.json");
await writeFile(capturePath, JSON.stringify(capture));
const exported = await cli(
"export-web-scripts",
capturePath,
join(root, "export"),
join(root.path, "export"),
);
const manifest = exported.normalized_result;
const manifestBytes = await readFile(manifest.manifest.path);
@@ -86,7 +106,7 @@ export async function verifyBrowserSourceMap(
scriptIndex >= 0,
"Actual browser capture omitted compiled source",
);
const mapPath = join(root, "app.js.map");
const mapPath = join(root.path, "app.js.map");
await writeFile(mapPath, site.map);
const before = site.mapRequests();
await client.connect(transport);
@@ -132,13 +152,11 @@ export async function verifyBrowserSourceMap(
public_point_cases: 4,
};
}, [
() => client?.close(),
() => transport?.close(),
() => site?.close(),
() =>
root === undefined
? undefined
: rm(root, { recursive: true, force: true }),
() => closeResource(client, "source-map MCP client"),
() => closeResource(transport, "source-map stdio transport"),
() => closeResource(site, "source-map fixture site"),
() => commands?.close(),
() => (commands === undefined ? root?.close() : undefined),
]);
}
function assertPoint(evidence, { site, input, capture, identity }) {
+40
View File
@@ -114,6 +114,46 @@ export const verifyKunaReferences = async ({
);
}
assert.equal(trace.value.truncated, false);
const summary = await call("trace_feature", {
query: name,
detail: "summary",
});
assert.equal(
summary.value.parent_evidence_id,
trace.structured.evidence_id,
);
assert.equal(
summary.value.summary.references,
trace.value.references.length,
);
assert.equal(
summary.value.summary.unknowns,
trace.value.residual_unknowns.length,
);
for (const [collection, values] of [
["matches", trace.value.matches],
["references", trace.value.references],
["unknowns", trace.value.residual_unknowns],
]) {
const view = await call("inspect_analysis_view", {
source: {
kind: "retained-evidence",
evidence_id: trace.structured.evidence_id,
},
view: {
kind: "native-feature-trace",
collection,
offset: 0,
limit: 1,
},
});
assert.equal(view.value.coverage.total, values.length);
assert.deepEqual(
view.value.items.rows.map(({ value }) => value),
values.slice(0, 1),
);
assert.equal(view.value.items.collection, collection);
}
}
composed = {
graph_ms: graph.ms,
+39
View File
@@ -17,6 +17,7 @@ import {
requireMcpOutput,
} from "./mcp-verifier-results.mjs";
import { verifyGhidraBoundaries } from "./real-ghidra-boundaries.mjs";
import { verifyGhidraProjectIdentity } from "./real-ghidra-project-identity.mjs";
const defaultEntrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url));
const environment = () => ({
@@ -214,6 +215,19 @@ async function verifyNativeValueAdapters(
);
assert.deepEqual(mcpEvidence.normalized_result, mcpGraph);
assert.deepEqual(mcpGraph, cliGraph);
const warmReply = await client.callTool(
{
name: "trace_native_values",
arguments: { procedure, max_depth: 2, max_functions: 8, limit: 3 },
},
{ timeout: 180000 },
);
const warmEvidence = parseEvidence(
requireMcpOutput(warmReply, "trace_native_values"),
);
const warmGraph = nativeValueTraceSchema.parse(
warmEvidence.normalized_result,
);
const analyzed = await client.callTool(
{
name: "analyze_function",
@@ -275,6 +289,25 @@ async function verifyNativeValueAdapters(
"MCP omitted structured Evidence bundle",
);
const bundle = requireMcpEvidenceBundle(bundleReply);
for (const [evidence, graph] of [
[mcpEvidence, mcpGraph],
[warmEvidence, warmGraph],
]) {
for (const source of [
...graph.procedures,
...graph.nodes,
...graph.edges,
]) {
assert.ok(evidence.evidence_links.includes(source.evidence_id));
assert.equal(
bundle.records.find(
(record) => record.evidence_id === source.evidence_id,
)?.operation,
"analyze_function",
`Missing source Evidence ${source.evidence_id}`,
);
}
}
for (const evidence of [
mcpEvidence,
functionEvidence,
@@ -317,6 +350,11 @@ async function verifyNativeValueAdapters(
await client.close();
await transport.close();
}
const projectIdentity = await verifyGhidraProjectIdentity({
target,
entrypoint,
env,
});
return {
mocked: false,
cli: true,
@@ -324,6 +362,7 @@ async function verifyNativeValueAdapters(
inline_evidence: true,
direct_function_comparison: true,
boundaries,
project_identity: projectIdentity,
nodes: cliGraph.total_nodes,
edges: cliGraph.total_edges,
decompilations: cliGraph.decompilations,
+13
View File
@@ -504,6 +504,9 @@ export async function verifyGhidraBoundaries(
// Prime both inventories before editing so stale names cannot hide in caches.
await call("list_names");
await call("list_procedures");
const traceInput = { procedure: address, max_depth: 0, max_functions: 1 };
const traceBefore = await evidence("trace_native_values", traceInput);
const sourceBefore = traceBefore.normalized_result.procedures[0].evidence_id;
const changes = {
procedure: address,
name: "dead",
@@ -517,6 +520,16 @@ export async function verifyGhidraBoundaries(
comment: changes.comment,
inline_comment: changes.inline_comment,
});
const traceAfter = await evidence("trace_native_values", traceInput);
const sourceAfter = traceAfter.normalized_result.procedures[0].evidence_id;
assert.notEqual(sourceBefore, sourceAfter);
const sourceProcedure = (evidenceId) =>
call("inspect_analysis_view", {
source: { kind: "retained-evidence", evidence_id: evidenceId },
view: { kind: "native", facet: "procedure", offset: 0, limit: 1 },
});
assert.equal((await sourceProcedure(sourceBefore)).item.name, name);
assert.equal((await sourceProcedure(sourceAfter)).item.name, changes.name);
const regexPattern = "^(a|aa)*b$";
const regexFailure = await client.callTool(
{
@@ -0,0 +1,249 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
appendFile,
copyFile,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { basename, join } from "node:path";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { requireMcpToolError } from "./mcp-verifier-results.mjs";
const exec = promisify(execFile);
/** Verify selected-project identity refusal through real CLI and MCP adapters. */
export async function verifyGhidraProjectIdentity({ target, entrypoint, env }) {
if (process.platform === "win32")
return {
status: "not-applicable",
reason: "Existing Ghidra projects are not admitted on Windows.",
};
assert.ok(env.GHIDRA_INSTALL_DIR, "Ghidra installation is required");
const root = await mkdtemp(join(env.TMPDIR, "rea-project-identity-"));
const targetPath = target.path;
const different = join(root, "different-target");
const headless = join(env.GHIDRA_INSTALL_DIR, "support", "analyzeHeadless");
const fixtureEnv = {
...env,
HOME: root,
XDG_CONFIG_HOME: join(root, "config"),
XDG_CACHE_HOME: join(root, "cache"),
XDG_DATA_HOME: join(root, "data"),
JDK_JAVA_OPTIONS: `-Duser.home="${root}"`,
};
const program = basename(targetPath);
const known = join(root, "known.gpr");
const unknown = join(root, "unknown.gpr");
const runtimeRootsBefore = (await readdir(env.TMPDIR))
.filter((name) => name.startsWith("rea-ghidra-"))
.sort();
const sha = async (path) =>
createHash("sha256")
.update(await readFile(path))
.digest("hex");
try {
await copyFile(targetPath, different);
await appendFile(different, "\nREA_IDENTITY_VERIFIER\n");
assert.notEqual(await sha(different), await sha(targetPath));
await writeFile(
join(root, "ClearImportDigest.java"),
`import ghidra.app.script.GhidraScript;
public class ClearImportDigest extends GhidraScript {
@Override public void run() {
currentProgram.setExecutableSHA256(null);
println("REA_DIGEST_AFTER=" + currentProgram.getExecutableSHA256());
}
}\n`,
);
await exec(
headless,
[root, "known", "-import", targetPath, "-noanalysis"],
{ env: fixtureEnv, timeout: 180000, maxBuffer: 8 * 1024 * 1024 },
);
const cleared = await exec(
headless,
[
root,
"unknown",
"-import",
targetPath,
"-noanalysis",
"-scriptPath",
root,
"-postScript",
"ClearImportDigest.java",
],
{ env: fixtureEnv, timeout: 180000, maxBuffer: 8 * 1024 * 1024 },
);
assert.match(cleared.stdout, /REA_DIGEST_AFTER=unknown/u);
const before = await projectState([known, unknown, targetPath, different]);
const knownEnv = {
...env,
REA_GHIDRA_PROJECT_PATH: known,
REA_GHIDRA_PROGRAM_NAME: program,
};
const unknownEnv = {
...env,
REA_GHIDRA_PROJECT_PATH: unknown,
REA_GHIDRA_PROGRAM_NAME: program,
};
const mismatch = await selectedProjectMcp({
entrypoint,
env: knownEnv,
first: different,
matching: targetPath,
expected: "import_identity_mismatch",
});
const unavailable = await selectedProjectMcp({
entrypoint,
env: unknownEnv,
first: targetPath,
expected: "import_identity_unavailable",
});
for (const [label, selectedEnv, input, expected] of [
["mismatch", knownEnv, different, "import_identity_mismatch"],
["unavailable", unknownEnv, targetPath, "import_identity_unavailable"],
]) {
let failed;
try {
await exec(
process.execPath,
[
entrypoint,
"decompile",
input,
"0x0",
"--provider",
"ghidra",
"--format",
"json",
],
{ env: selectedEnv, timeout: 180000, maxBuffer: 8 * 1024 * 1024 },
);
} catch (cause) {
assert.equal(cause.code, 1, `${label} CLI exit`);
failed = JSON.parse(cause.stdout);
}
assert.ok(failed, `${label} CLI must refuse`);
assert.equal(failed.code, "configuration_invalid");
assert.equal(failed.details.diagnostics.remote_code, expected);
}
assert.deepEqual(
await projectState([known, unknown, targetPath, different]),
before,
);
assert.deepEqual(
(await readdir(env.TMPDIR))
.filter((name) => name.startsWith("rea-ghidra-"))
.sort(),
runtimeRootsBefore,
"Selected-project checks left a Ghidra runtime directory",
);
return {
status: "verified",
mismatch,
unavailable,
cli_refusals: true,
source_files_unchanged: true,
};
} finally {
await rm(root, { recursive: true, force: true });
}
}
async function selectedProjectMcp({
entrypoint,
env,
first,
matching,
expected,
}) {
const client = new Client({
name: "rea-project-identity-verifier",
version: "1",
});
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
stderr: "pipe",
});
try {
await client.connect(transport);
const tools = new Map(
(await client.listTools()).tools.map((tool) => [tool.name, tool]),
);
const call = (name, arguments_) =>
client.callTool(
{ name, arguments: arguments_ },
{ timeout: 180000, toolDefinition: tools.get(name) },
);
assert.notEqual(
(await call("open_binary", { path: first, provider_id: "ghidra" }))
.isError,
true,
);
const rejected = requireMcpToolError(await call("list_documents", {}));
assert.equal(
rejected.code,
"configuration_invalid",
JSON.stringify({
code: rejected.code,
message: rejected.message,
failure_kind: rejected.details?.diagnostics?.failure_kind,
failure_message: rejected.details?.diagnostics?.failure_message,
stdout: rejected.details?.diagnostics?.stdout?.text?.slice(-2500),
stderr: rejected.details?.diagnostics?.stderr?.text?.slice(-1000),
}),
);
assert.equal(rejected.details.diagnostics.remote_code, expected);
if (matching !== undefined) {
assert.notEqual(
(await call("open_binary", { path: matching, provider_id: "ghidra" }))
.isError,
true,
);
assert.notEqual((await call("list_documents", {})).isError, true);
}
assert.notEqual((await call("close_binary", {})).isError, true);
return {
refused: expected,
matching_target_reopened: matching !== undefined,
};
} finally {
await client.close();
await transport.close();
}
}
async function projectState(paths) {
const files = [];
const walk = async (dir) => {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) await walk(path);
else if (entry.isFile()) files.push(path);
}
};
for (const path of paths) {
files.push(path);
if (path.endsWith(".gpr")) await walk(`${path.slice(0, -4)}.rep`);
}
files.sort();
return Promise.all(
files.map(async (path) => [
path,
createHash("sha256")
.update(await readFile(path))
.digest("hex"),
]),
);
}
+2 -9
View File
@@ -70,15 +70,8 @@ if (!(await compiledRuntimeExists(runtimeFiles))) {
guardClosedStdout();
// Doctor runs outside Incur, so handle its global timing flag here. Keep a
// token consumed by --format as a value, even when it looks like a flag.
const outputValues = new Set(
builtinArguments
.splitIncurBuiltinArguments(doctorArguments)
.outputValues.map(({ index }) => index),
);
const doctorCliArguments = doctorArguments.filter(
(argument, index) => argument !== "--timings" || outputValues.has(index),
);
const timingRequested = doctorCliArguments.length !== doctorArguments.length;
const { arguments: doctorCliArguments, enabled: timingRequested } =
builtinArguments.splitCliTimingArguments(doctorArguments);
const started = performance.now();
try {
const { runProductionMcpDoctorCli } =
+9
View File
@@ -15,12 +15,16 @@ const [
{ parseConfig },
{ silentLogger },
{ PROMPT_CONTRACTS },
{ TOOL_CONTRACTS },
{ getEvidenceInputContract },
] = await Promise.all([
read("server/createServer.js"),
read("composition/binary.js"),
read("config/parseConfig.js"),
read("logger.js"),
read("contracts/promptContracts.js"),
read("contracts/toolContracts.js"),
read("domain/evidenceInputContract.js"),
]);
const recordCatalog = async (profile) => {
// Catalog generation is independent of the host account and user configuration.
@@ -71,6 +75,11 @@ const recordCatalog = async (profile) => {
instructions: client.getInstructions(),
identity: server.catalogIdentity,
tools,
evidenceReferenceAlternatives: TOOL_CONTRACTS.filter(
(contract) =>
getEvidenceInputContract(contract.inputSchema)
.hasEvidenceReferenceAlternative,
).map((contract) => contract.name),
prompts,
};
const encoded = JSON.stringify(catalog);
@@ -161,6 +161,38 @@ try {
fromNumericOffset.normalized_result,
fromOffset.normalized_result,
);
const missingProcedure = "rea_missing_procedure_fixture";
const missingName = await client.callTool(
{
name: "procedure_pseudo_code",
arguments: { procedure: missingProcedure },
},
{ timeout: 240000 },
);
assert.equal(missingName.isError, true);
const missingNameError = JSON.parse(
missingName.content.find((item) => item.type === "text").text,
).error;
assert.equal(missingNameError.code, "invalid_request");
assert.match(missingNameError.message, /rea_missing_procedure_fixture/u);
assert.match(missingNameError.remediation.action, /list_procedures/u);
assert.match(missingNameError.remediation.action, /does not prove/u);
assert.equal(missingNameError.recovery, undefined);
const unmappedOffset = await client.callTool(
{
name: "procedure_pseudo_code",
arguments: { procedure: "file-offset:0x1fffffffffffff" },
},
{ timeout: 240000 },
);
assert.equal(unmappedOffset.isError, true);
const unmappedError = JSON.parse(
unmappedOffset.content.find((item) => item.type === "text").text,
).error;
assert.equal(unmappedError.code, "invalid_request");
assert.match(unmappedError.message, /no loaded source mapping/u);
assert.match(unmappedError.remediation.action, /inspect_native_load_image/u);
assert.equal(unmappedError.recovery, undefined);
const missingAddress = "ram:0xffffffff";
const rejected = await client.callTool(
{ name: "procedure_pseudo_code", arguments: { procedure: missingAddress } },
+62
View File
@@ -276,6 +276,67 @@ try {
assert.equal(updated.annotations.inline_comment, changes.inline_comment);
assert.equal(updated.dossier.procedure.name, changes.name);
assert.ok(updated.dossier.pseudocode.includes(changes.name));
// Exercise the documented recovery through the public MCP catalog on real
// Ghidra, rather than assuming the provider exposes direct comment methods.
const contextCall = async (name, args) => {
const definition = tools.find((tool) => tool.name === name);
assert.ok(definition?.outputSchema, `${name} must be advertised`);
const response = await client.callTool(
{ name, arguments: args },
{ timeout: 240000, toolDefinition: definition },
);
return requireMcpOutput(response, name);
};
const context = (
await contextCall("inspect_address_context", {
address: fixture.entry,
})
).result;
assert.equal(context.procedure.state, "available");
assert.equal(context.procedure.value.found, true);
for (const facet of [context.comment, context.inline_comment]) {
assert.equal(facet.state, "unavailable");
assert.match(
facet.remediation,
/analyze_function.*parent_evidence_id.*inspect_analysis_view/u,
);
}
assert.match(context.bookmarks.remediation, /binary_session/u);
const summary = parseEvidence(
await contextCall("analyze_function", {
procedure: context.procedure.value.procedure.address,
}),
).normalized_result;
const comments = parseEvidence(
await contextCall("inspect_analysis_view", {
source: {
kind: "retained-evidence",
evidence_id: summary.parent_evidence_id,
},
view: { kind: "native", facet: "comments" },
}),
).normalized_result;
for (const [kind, text] of [
["comment", changes.comment],
["inline", changes.inline_comment],
])
assert.ok(
comments.item.some(
(item) =>
item.address === context.procedure.value.query_address &&
item.kind === kind &&
item.text === text,
),
);
const outside = (
await contextCall("inspect_address_context", {
address: unlistedAddress,
})
).result;
assert.equal(outside.procedure.state, "available");
assert.equal(outside.procedure.value.found, false);
assert.equal(outside.comment.state, "unavailable");
assert.match(outside.comment.remediation, /binary_session/u);
assert.equal(
await call("procedure_address", { procedure: changes.name }),
fixture.entry,
@@ -386,6 +447,7 @@ try {
fresh_decompiler: true,
clear_comments: true,
cli_mcp_parity: true,
address_context_comment_recovery: true,
},
context_input_errors_preserved: true,
original_unchanged: true,
+34 -2
View File
@@ -46,8 +46,40 @@ async function expectRemoteFailure(client, operation, parameters, code) {
return { operation, code };
}
async function verifyBridgeRejections(client) {
async function verifyBridgeRejections(client, names) {
const global = names.find((item) =>
matchesSymbol(item.value, "rea_ghidra_inventory_global"),
);
if (global === undefined)
throw new Error("Ghidra initialized global probe is unavailable");
const mapping = await call(client, "address_to_file_offset", {
document: null,
address: global.address,
});
if (!Number.isSafeInteger(mapping.file_offset))
throw new Error("Ghidra initialized global has no exact file offset");
return {
unknownProcedure: await expectRemoteFailure(
client,
"procedure_address",
{ document: null, procedure: "rea_missing_procedure_fixture" },
"procedure_not_found",
),
mappedDataOffset: await expectRemoteFailure(
client,
"procedure_pseudo_code",
{
document: null,
procedure: `file-offset:0x${mapping.file_offset.toString(16)}`,
},
"procedure_not_found",
),
unmappedFileOffset: await expectRemoteFailure(
client,
"procedure_pseudo_code",
{ document: null, procedure: "file-offset:0x1fffffffffffff" },
"file_offset_unmapped",
),
unknownDocument: await expectRemoteFailure(
client,
"list_segments",
@@ -86,7 +118,7 @@ export async function verifyInventoryOperations({
});
const rejections =
variant === "debug" ? await verifyBridgeRejections(client) : null;
variant === "debug" ? await verifyBridgeRejections(client, names) : null;
const stringSearch = await call(client, "search_strings", {
pattern: String.raw`^REA_GHIDRA_(?:INVENTORY_ENTRY|LEAF_VALUE)$`,
mode: "regex",
+119 -1
View File
@@ -6,12 +6,13 @@ import {
mkdtemp,
mkdir,
readFile,
readdir,
realpath,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { basename, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
@@ -41,8 +42,10 @@ import {
import {
requireMcpToolError,
requireMcpOperationResult,
requireMcpOutput,
} from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
import { runOwnedCommand } from "../dist/process/OwnedCommand.js";
// Optional cross-target lane. No tool acquisition and no target execution.
const mode = process.argv[2];
@@ -90,6 +93,114 @@ for (const setting of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) {
if (process.env[setting] !== undefined) env[setting] = process.env[setting];
}
/** Observe an existing project, and verify REA never alters the caller's copy. */
async function verifyExistingMipsProject(fixture, byteOrder) {
const projectDir = join(workspace, `${byteOrder}-existing-project`);
await mkdir(projectDir);
assert.ok(installation.analyzeHeadlessPath);
await runOwnedCommand(
{
command: installation.analyzeHeadlessPath,
arguments: [
projectDir,
"ExistingMips",
"-import",
fixture.path,
"-noanalysis",
],
env,
runId: run.run_id,
expectedCommand: null,
},
{ timeoutMs: 180000, diagnosticBytes: 1024 * 1024 },
);
const projectPath = join(projectDir, "ExistingMips.gpr");
const digestTree = async () => {
const paths = [projectPath];
const walk = async (dir) => {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) await walk(path);
else if (entry.isFile()) paths.push(path);
}
};
await walk(join(projectDir, "ExistingMips.rep"));
return Promise.all(
paths.sort().map(async (path) => [
relative(projectDir, path),
createHash("sha256")
.update(await readFile(path))
.digest("hex"),
]),
);
};
const before = await digestTree();
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env: {
...env,
REA_GHIDRA_PROJECT_PATH: projectPath,
REA_GHIDRA_PROGRAM_NAME: basename(fixture.path),
},
stderr: "pipe",
});
const client = new Client({ name: "rea-existing-mips-proof", version: "1" });
try {
await client.connect(transport);
const opened = await client.callTool(
{
name: "open_binary",
arguments: { path: fixture.path, provider_id: "ghidra" },
},
{ timeout: 180000 },
);
requireMcpOperationResult(opened, "open_binary");
const result = await client.callTool(
{ name: "list_procedures", arguments: {} },
{ timeout: 180000 },
);
const observation = parseEvidence(
requireMcpOutput(result, "list_procedures"),
);
assert.ok(Array.isArray(observation.normalized_result));
const parameters = observation.analysis_profile?.parameters;
assert.equal(parameters?.import_mode, "existing-project-private-copy-v1");
assert.equal(parameters?.mips_elf?.byte_order, byteOrder);
assert.equal(parameters?.mips_support_lane, undefined);
assert.deepEqual(parameters?.original_analysis, {
completeness: "unknown",
settings: "unknown",
});
requireMcpOperationResult(
await client.callTool({ name: "close_binary", arguments: {} }),
"close_binary",
);
} finally {
try {
await client.close();
} finally {
await transport.close();
}
}
assert.deepEqual(
await digestTree(),
before,
"REA changed the source project",
);
assert.equal(
createHash("sha256")
.update(await readFile(fixture.path))
.digest("hex"),
fixture.sha256,
);
return {
byte_order: byteOrder,
project_unchanged: true,
target_unchanged: true,
};
}
/**
* Typed float signatures must use soft-float o32 storage, and callers must see
* the source constants through it rather than FPU inputs.
@@ -320,6 +431,10 @@ try {
);
}
assert.equal(resolved.value.sha256, fixture.sha256);
const existingProject =
!psp && floatAbi === "hard"
? await verifyExistingMipsProject(fixture, byteOrder)
: undefined;
const snapshotPath = join(
workspace,
`${byteOrder}-${floatAbi}.snapshot.json`,
@@ -682,6 +797,9 @@ try {
}
reports.push({
...fixture,
...(existingProject === undefined
? {}
: { existing_project: existingProject }),
independent_reader: independent,
effective_language: image.observations.language_id,
effective_compiler_spec: image.observations.compiler_spec_id,
+425 -6
View File
@@ -1,7 +1,14 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import {
mkdtemp,
mkdir,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -11,12 +18,18 @@ import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../dist/domain/evidence.js";
import { cleanupOwnedProcessGroup } from "../dist/process/ProcessOwnership.js";
import {
ProviderProcessSupervisor,
spawnOwnedProviderProcess,
} from "../dist/process/ProviderProcess.js";
import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs";
import {
requireMcpLifecycleResult,
requireMcpOutput,
} from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
if (process.argv.length !== 2)
throw new Error("Usage: node scripts/verify-real-ghidra-raw.mjs");
@@ -39,6 +52,7 @@ assert.equal(installation.status, "available", JSON.stringify(installation));
const run = createVerifierRun();
const workspace = await mkdtemp(join(tmpdir(), "rea-raw-proof-"));
let fixtureCleaned = true;
const runtime = join(workspace, "runtime");
await mkdir(runtime);
const bytes = Buffer.from("1eff2fe1", "hex"); // ARM little-endian bx lr.
@@ -53,6 +67,17 @@ const env = {
REA_ANALYSIS_PROVIDER: "ghidra",
REA_PROCESS_RUN_ID: run.run_id,
GHIDRA_INSTALL_DIR: process.env.GHIDRA_INSTALL_DIR,
...(process.env.REA_MCP_INPUT_SCHEMA_PROFILE === undefined
? {}
: {
REA_MCP_INPUT_SCHEMA_PROFILE: process.env.REA_MCP_INPUT_SCHEMA_PROFILE,
}),
...(process.env.REA_MCP_OUTPUT_SCHEMA_PROFILE === undefined
? {}
: {
REA_MCP_OUTPUT_SCHEMA_PROFILE:
process.env.REA_MCP_OUTPUT_SCHEMA_PROFILE,
}),
...(process.env.JAVA_HOME === undefined
? {}
: { JAVA_HOME: process.env.JAVA_HOME }),
@@ -78,8 +103,13 @@ transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
let report;
let searchDefinition;
try {
await client.connect(transport);
searchDefinition = (await client.listTools()).tools.find(
(tool) => tool.name === "search_bytes",
);
assert.ok(searchDefinition?.outputSchema);
const profiles = [];
const sessions = [];
for (const entryPoint of ["0x08000000", "0x8000000", "0x09000000"]) {
@@ -300,6 +330,198 @@ try {
assert.equal(wrongCursor.isError, true);
await call("close_binary");
assert.deepEqual(await readFile(repeatedPath), repeatedBytes);
// Two adjacent virtual regions backed by disjoint file ranges. The same
// signature crosses their boundary and overlaps at every possible start.
const splitPath = join(workspace, "split.elf");
const splitBytes = splitSearchElf(0);
const splitSha256 = createHash("sha256").update(splitBytes).digest("hex");
await writeFile(splitPath, splitBytes);
await call("open_binary", { path: splitPath, provider_id: "ghidra" });
let cursorReopened = false;
for (const pattern of ["aa aa aa", "aa ?? aa"]) {
let cursor;
const addresses = [];
const pages = [];
do {
const evidence = await call(
"search_bytes",
{
pattern,
start: "0x401000",
end: "0x401007",
limit: 2,
...(cursor === undefined ? {} : { cursor }),
},
splitSha256,
"elf",
);
const result = evidence.normalized_result;
addresses.push(...result.matches.map((match) => match.address));
pages.push({
matches: result.matches.map(({ address, segment, file_offset }) => ({
address,
segment,
file_offset,
})),
blocks: result.searched.blocks,
});
cursor = result.next_cursor ?? undefined;
if (cursor !== undefined && !cursorReopened) {
const snapshot = join(workspace, "split-snapshot.json");
await call("close_binary", { snapshot_path: snapshot });
await call("open_binary", {
path: splitPath,
provider_id: "ghidra",
snapshot_path: snapshot,
});
cursorReopened = true;
}
} while (cursor !== undefined);
assert.deepEqual(addresses, [
"0x401000",
"0x401001",
"0x401002",
"0x401003",
"0x401004",
"0x401005",
]);
assert.deepEqual(pages, [
{
matches: [
{ address: "0x401000", segment: "segment_0", file_offset: 0x1000 },
{ address: "0x401001", segment: "segment_0", file_offset: 0x1001 },
],
blocks: [
{
segment: "segment_0",
start: "0x401000",
end: "0x401003",
bytes: 4,
},
{
segment: "segment_1",
start: "0x401004",
end: "0x401004",
bytes: 1,
},
],
},
{
matches: [
{ address: "0x401002", segment: "segment_0", file_offset: 0x1002 },
{ address: "0x401003", segment: "segment_0", file_offset: 0x1003 },
],
blocks: [
{
segment: "segment_0",
start: "0x401002",
end: "0x401003",
bytes: 2,
},
{
segment: "segment_1",
start: "0x401004",
end: "0x401006",
bytes: 3,
},
],
},
{
matches: [
{ address: "0x401004", segment: "segment_1", file_offset: 0x3000 },
{ address: "0x401005", segment: "segment_1", file_offset: 0x3001 },
],
blocks: [
{
segment: "segment_1",
start: "0x401004",
end: "0x401007",
bytes: 4,
},
],
},
]);
const bounded = await call(
"search_bytes",
{
pattern,
start: "0x401000",
end: "0x401005",
limit: 20,
},
splitSha256,
"elf",
);
assert.deepEqual(
bounded.normalized_result.matches.map((match) => match.address),
addresses.slice(0, 4),
);
}
assert.equal(cursorReopened, true);
const fileStart = await call(
"address_to_file_offset",
{ address: "0x401000" },
splitSha256,
"elf",
);
const fileSecond = await call(
"address_to_file_offset",
{ address: "0x401004" },
splitSha256,
"elf",
);
assert.equal(fileStart.normalized_result.file_offset, 0x1000);
assert.equal(fileSecond.normalized_result.file_offset, 0x3000);
await call("close_binary");
assert.deepEqual(await readFile(splitPath), splitBytes);
await verifyCrossBlockProject(splitPath, splitSha256);
const gapPath = join(workspace, "gap.elf");
const gapBytes = splitSearchElf(1);
const gapSha256 = createHash("sha256").update(gapBytes).digest("hex");
await writeFile(gapPath, gapBytes);
await call("open_binary", { path: gapPath, provider_id: "ghidra" });
const gap = await call(
"search_bytes",
{
pattern: "aa aa aa aa ?? aa aa aa aa",
start: "0x401000",
end: "0x401008",
},
gapSha256,
"elf",
);
assert.deepEqual(gap.normalized_result.matches, []);
await call("close_binary");
assert.deepEqual(await readFile(gapPath), gapBytes);
const zeroFillPath = join(workspace, "initialized-zerofill.elf");
const zeroFillBytes = splitSearchElf(1, true);
const zeroFillSha256 = createHash("sha256")
.update(zeroFillBytes)
.digest("hex");
await writeFile(zeroFillPath, zeroFillBytes);
await call("open_binary", { path: zeroFillPath, provider_id: "ghidra" });
const zeroFill = await call(
"search_bytes",
{
pattern: "aa aa aa aa ?? aa aa aa aa",
start: "0x401000",
end: "0x401008",
},
zeroFillSha256,
"elf",
);
// This Ghidra loader initializes the ELF zero-fill byte in this fixture.
assert.deepEqual(
zeroFill.normalized_result.matches.map((match) => match.address),
["0x401000"],
);
assert.deepEqual(
zeroFill.normalized_result.searched.skipped_uninitialized,
[],
);
await call("close_binary");
assert.deepEqual(await readFile(zeroFillPath), zeroFillBytes);
report = {
ok: true,
mocked: false,
@@ -321,6 +543,7 @@ try {
original_unchanged: true,
x86_targets: x86Targets,
byte_search: {
cross_block_paging_and_gap: true,
matches: 1005,
page_size: 1000,
first_search_elapsed_ms: searchElapsedMs,
@@ -336,7 +559,7 @@ try {
} finally {
await client.close();
await transport.close();
await rm(workspace, { recursive: true, force: true });
if (fixtureCleaned) await rm(workspace, { recursive: true, force: true });
}
const verifierRun = await completeVerifierRun(run);
assert.equal(verifierRun.process_lineage.status, "verified");
@@ -349,15 +572,211 @@ process.stdout.write(
}) + "\n",
);
async function call(name, args = {}, targetSha256 = sha256) {
async function call(
name,
args = {},
targetSha256 = sha256,
targetFormat = "raw-binary",
) {
const result = await client.callTool(
{ name, arguments: args },
{ timeout: 240000 },
{
timeout: 240000,
...(name === "search_bytes" ? { toolDefinition: searchDefinition } : {}),
},
);
if (["open_binary", "close_binary", "binary_session"].includes(name))
return requireMcpLifecycleResult(result, name);
const evidence = parseEvidence(requireMcpOutput(result, name));
const output = requireMcpOutput(result, name);
const evidence = parseEvidence(output);
assert.equal(evidence.subject.digest.sha256, targetSha256);
assert.equal(evidence.subject.format, "raw-binary");
assert.equal(evidence.subject.format, targetFormat);
return evidence;
}
// Minimal static ELF64 with adjacent (or one-byte-separated) virtual segments
// and noncontiguous file backing. The target is never executed.
function splitSearchElf(gap, zeroFill = false) {
const result = Buffer.alloc(0x3004);
result.set([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0]);
result.writeUInt16LE(2, 16); // ET_EXEC
result.writeUInt16LE(62, 18); // EM_X86_64
result.writeUInt32LE(1, 20);
result.writeBigUInt64LE(0x401000n, 24);
result.writeBigUInt64LE(64n, 32);
result.writeUInt16LE(64, 52);
result.writeUInt16LE(56, 54);
result.writeUInt16LE(2, 56);
const segment = (offset, fileOffset, address, memorySize = 4) => {
result.writeUInt32LE(1, offset); // PT_LOAD
result.writeUInt32LE(4, offset + 4); // PF_R
result.writeBigUInt64LE(BigInt(fileOffset), offset + 8);
result.writeBigUInt64LE(BigInt(address), offset + 16);
result.writeBigUInt64LE(BigInt(address), offset + 24);
result.writeBigUInt64LE(4n, offset + 32);
result.writeBigUInt64LE(BigInt(memorySize), offset + 40);
result.writeBigUInt64LE(1n, offset + 48);
result.fill(0xaa, fileOffset, fileOffset + 4);
};
segment(64, 0x1000, 0x401000, zeroFill ? 5 : 4);
segment(120, 0x3000, 0x401004 + gap);
return result;
}
// Add test-only memory to an owned project, then let REA inspect its private copy.
async function verifyCrossBlockProject(target, targetSha256) {
const projectDirectory = join(workspace, "boundary-project");
await mkdir(projectDirectory);
const scriptDirectory = fileURLToPath(
new URL("../tests/conformance/ghidra", import.meta.url),
);
const launch = await spawnOwnedProviderProcess({
command: installation.analyzeHeadlessPath,
arguments: [
projectDirectory,
"Boundary",
"-import",
target,
"-noanalysis",
"-scriptPath",
scriptDirectory,
"-postScript",
"ReaCrossBlockFixture.java",
],
env,
runId: run.run_id,
expectedCommand: null,
});
fixtureCleaned = false;
const supervisor = new ProviderProcessSupervisor({
...launch,
ownsProcessLifetime: true,
cleanup: () => cleanupOwnedProcessGroup(launch.ownership),
});
await withVerifierCleanup(async () => {
assert.ok(await supervisor.waitForExit(180000), "Fixture setup timed out");
assert.ok(
await supervisor.waitForOutputClose(1000),
"Fixture output did not close",
);
const snapshot = supervisor.snapshot();
const output = `${snapshot.stdout.text}\n${snapshot.stderr.text}`;
assert.equal(snapshot.exitCode, 0, output);
assert.doesNotMatch(output, /REPORT SCRIPT ERROR/u);
}, [
async () => {
const stopped = await supervisor.stop();
fixtureCleaned = stopped.status === "verified-cleanup";
assert.ok(
fixtureCleaned,
`Owned cleanup incomplete; retained ${workspace}: ${JSON.stringify(stopped)}`,
);
},
]);
const beforeProject = await fingerprintFiles(projectDirectory);
const projectTransport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env: {
...env,
REA_GHIDRA_PROJECT_PATH: join(projectDirectory, "Boundary.gpr"),
},
stderr: "pipe",
});
const projectClient = new Client({
name: "rea-cross-block-project-proof",
version: "1",
});
try {
await projectClient.connect(projectTransport);
const definition = (await projectClient.listTools()).tools.find(
(tool) => tool.name === "search_bytes",
);
assert.ok(definition?.outputSchema);
const projectCall = async (name, args) => {
const reply = await projectClient.callTool(
{ name, arguments: args },
{
timeout: 240000,
...(name === "search_bytes" ? { toolDefinition: definition } : {}),
},
);
if (name === "open_binary" || name === "close_binary")
return requireMcpLifecycleResult(reply, name);
const output = requireMcpOutput(reply, name);
const evidence = parseEvidence(output);
assert.equal(evidence.subject.digest.sha256, targetSha256);
return evidence.normalized_result;
};
await projectCall("open_binary", { path: target, provider_id: "ghidra" });
const pattern = "de ad ?? ?? be ef";
const adjacent = await projectCall("search_bytes", {
pattern,
start: "0x60000000",
end: "0x60000005",
});
assert.deepEqual(
adjacent.matches.map((match) => match.address),
["0x60000000"],
);
const gap = await projectCall("search_bytes", {
pattern,
start: "0x60000100",
end: "0x60000105",
});
assert.deepEqual(gap.matches, []);
assert.ok(
gap.searched.skipped_uninitialized.some(
(block) => block.start === "0x60000102" && block.end === "0x60000103",
),
);
const all = await projectCall("search_bytes", { pattern });
assert.ok(all.matches.some((match) => match.address === "0x60000000"));
assert.ok(
!all.matches.some((match) => match.address.startsWith("overlay_test:")),
);
assert.ok(
all.searched.skipped_not_loaded.some(
(block) => block.segment === "overlay_test",
),
);
const overlay = await projectCall("search_bytes", {
pattern,
start: "overlay_test:0x60000000",
end: "overlay_test:0x60000005",
});
assert.deepEqual(
overlay.matches.map((match) => match.address),
["overlay_test:0x60000000"],
);
await projectCall("close_binary", {});
} finally {
await projectClient.close();
await projectTransport.close();
}
assert.deepEqual(await fingerprintFiles(projectDirectory), beforeProject);
}
async function fingerprintFiles(directory) {
const result = [];
const visit = async (relative) => {
const entries = await readdir(join(directory, relative), {
withFileTypes: true,
});
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
const name = join(relative, entry.name);
if (entry.isDirectory()) await visit(name);
else if (entry.isFile())
result.push([
name,
createHash("sha256")
.update(await readFile(join(directory, name)))
.digest("hex"),
]);
else throw new Error(`Unexpected project entry: ${name}`);
}
};
await visit("");
return result;
}
+24
View File
@@ -511,6 +511,30 @@ const verifyKuna = async (
references,
});
}
if (functions.length > 0) {
const trace = await cold.call("trace_native_values", {
procedure: functions[0][1],
max_depth: 0,
max_functions: 1,
});
const bundle = await cold.call("get_evidence_bundle", {
detail: "complete",
});
for (const source of [
...trace.value.procedures,
...trace.value.nodes,
...trace.value.edges,
]) {
assert.ok(trace.structured.evidence_links.includes(source.evidence_id));
assert.equal(
bundle.value.records.find(
(record) => record.evidence_id === source.evidence_id,
)?.operation,
"analyze_function",
`Missing source Evidence ${source.evidence_id}`,
);
}
}
} catch (cause) {
coldFailure = cause;
} finally {
+108 -2
View File
@@ -65,7 +65,21 @@ it("recognizes only the completed empty false result as absence", async () => {
killed: false,
}),
);
await expect(probe()).resolves.toEqual({ ok: true, value: false });
await expect(probe()).resolves.toEqual({
ok: true,
value: {
matches: false,
capturedOutput: {
exit_code: 1,
stdout: "",
stderr: "",
stdout_bytes: 0,
stderr_bytes: 0,
signal: null,
truncated: false,
},
},
});
});
it.each([
@@ -124,7 +138,21 @@ it("recognizes an empty successful probe as existence", async () => {
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
});
await expect(probe()).resolves.toEqual({ ok: true, value: true });
await expect(probe()).resolves.toEqual({
ok: true,
value: {
matches: true,
capturedOutput: {
exit_code: 0,
stdout: "",
stderr: "",
stdout_bytes: 0,
stderr_bytes: 0,
signal: null,
truncated: false,
},
},
});
});
it.each(["stdout", "stderr"] as const)(
@@ -265,3 +293,81 @@ it("keeps a physical failure even when the later abort reason is that same error
error: { _tag: "AnalysisProtocolError", cause: failure },
});
});
const STARTUP_NOTICES = [
"* daemon not running; starting now at tcp:5037\n* daemon started successfully\n",
"* daemon not running. starting it now on port 5037 *\r\n* daemon started successfully *\r\n",
];
it.each(STARTUP_NOTICES)(
"recognizes completed true and false probes with successful startup: %j",
async (stderr) => {
vi.mocked(execFileBytes).mockResolvedValueOnce({
stdout: Buffer.alloc(0),
stderr: Buffer.from(stderr),
});
await expect(probe()).resolves.toMatchObject({
ok: true,
value: {
matches: true,
capturedOutput: {
stdout: "",
stderr,
exit_code: 0,
stderr_bytes: Buffer.byteLength(stderr),
},
},
});
vi.mocked(execFileBytes).mockRejectedValueOnce(
Object.assign(new Error("false"), {
code: 1,
stdout: "",
stderr,
signal: null,
killed: false,
}),
);
await expect(probe()).resolves.toMatchObject({
ok: true,
value: {
matches: false,
capturedOutput: {
stdout: "",
stderr,
exit_code: 1,
stderr_bytes: Buffer.byteLength(stderr),
},
},
});
},
);
it.each([
"* daemon not running; starting now at tcp:5037\n",
"* daemon failed to start\n",
"* daemon started successfully\n* daemon not running; starting now at tcp:5037\n",
"* daemon started successfully\nunexpected\n",
"\n",
" ",
])("refuses an incomplete or unknown startup output: %j", async (stderr) => {
vi.mocked(execFileBytes).mockResolvedValueOnce({
stdout: Buffer.alloc(0),
stderr: Buffer.from(stderr),
});
await expect(probe()).resolves.toMatchObject({
ok: false,
error: { capturedOutput: { stderr, exit_code: 0 } },
});
vi.mocked(execFileBytes).mockRejectedValueOnce(
Object.assign(new Error("false"), {
code: 1,
stdout: "",
stderr,
signal: null,
killed: false,
}),
);
await expect(probe()).resolves.toMatchObject({
ok: false,
error: { capturedOutput: { stderr, exit_code: 1 } },
});
});
+47 -16
View File
@@ -16,7 +16,11 @@ import {
type ExecFileOutputFailure,
} from "../process/ExecFileOutput.js";
import { packageChangeFailureReason } from "./AdbShellOutput.js";
import { isSuccessfulDaemonStartNotice } from "./AdbDeviceOutput.js";
import {
isSuccessfulDaemonStartNotice,
isDaemonStartingNotice,
hasOnlySuccessfulAdbStartupNotices,
} from "./AdbDeviceOutput.js";
import type { AdbBinarySelection } from "./AdbConfiguration.js";
const STDERR_EXCERPT_CHARACTERS = 512;
@@ -164,12 +168,7 @@ export const runAdbCapture = async (
const line = stderr
.slice(diagnosticStart, newline === -1 ? stderr.length : newline)
.trimEnd();
if (
!isSuccessfulDaemonStartNotice(line) &&
!/^\* daemon not running(?:; starting now at [^\r\n]*|\. starting it now on port \d+ \*)$/u.test(
line,
)
)
if (!isSuccessfulDaemonStartNotice(line) && !isDaemonStartingNotice(line))
break;
diagnosticStart = newline === -1 ? stderr.length : newline + 1;
}
@@ -272,7 +271,38 @@ export const packageChangeRefusal = (
);
};
/** Only the clean false result of test -e establishes absence. */
/** Parsed predicate and the complete output that supports it. */
export interface AdbPathProbe {
readonly matches: boolean;
readonly capturedOutput: {
readonly stdout: string;
readonly stderr: string;
readonly stdout_bytes: number;
readonly stderr_bytes: number;
readonly exit_code: 0 | 1;
readonly signal: null;
readonly truncated: false;
};
}
const completedAdbPathProbe = (
matches: boolean,
stderr: string,
): AdbPathProbe => ({
matches,
capturedOutput: {
stdout: "",
stderr,
stdout_bytes: 0,
stderr_bytes: Buffer.byteLength(stderr),
exit_code: matches ? 0 : 1,
signal: null,
truncated: false,
},
});
const recognizedProbeStderr = (stderr: string): boolean =>
stderr === "" || hasOnlySuccessfulAdbStartupNotices(stderr);
/** Only a complete false predicate, with no device diagnostics, establishes absence. */
export const probeAdbPathExistence = async (
operation: string,
serial: string,
@@ -280,11 +310,11 @@ export const probeAdbPathExistence = async (
selection: AdbBinarySelection,
limits: AdbRunLimits,
signal: AbortSignal | undefined,
): Promise<Result<boolean, AnalysisError>> => {
): Promise<Result<AdbPathProbe, AnalysisError>> => {
return probeAdbPath(operation, serial, path, selection, limits, signal, "-e");
};
/** Only the clean true/false result of test -d establishes directory status. */
/** Completed predicate status is independent of recognized host startup notices. */
export const probeAdbPathDirectory = async (
operation: string,
serial: string,
@@ -292,7 +322,7 @@ export const probeAdbPathDirectory = async (
selection: AdbBinarySelection,
limits: AdbRunLimits,
signal: AbortSignal | undefined,
): Promise<Result<boolean, AnalysisError>> =>
): Promise<Result<AdbPathProbe, AnalysisError>> =>
probeAdbPath(operation, serial, path, selection, limits, signal, "-d");
const probeAdbPath = async (
@@ -303,7 +333,7 @@ const probeAdbPath = async (
limits: AdbRunLimits,
signal: AbortSignal | undefined,
predicate: "-d" | "-e",
): Promise<Result<boolean, AnalysisError>> => {
): Promise<Result<AdbPathProbe, AnalysisError>> => {
const fact =
predicate === "-d" ? "destination_directory" : "destination_existence";
const probed = await runAdbCapture(
@@ -315,8 +345,9 @@ const probeAdbPath = async (
signal,
);
if (probed.ok) {
if (probed.value.stdout.length === 0 && probed.value.stderr.length === 0)
return ok(true);
const stderr = probed.value.stderr.toString("utf8");
if (probed.value.stdout.length === 0 && recognizedProbeStderr(stderr))
return ok(completedAdbPathProbe(true, stderr));
return err(
new AnalysisProtocolError(
`The ADB path ${predicate === "-d" ? "directory" : "existence"} probe returned unexpected output; destination status is unknown`,
@@ -345,9 +376,9 @@ const probeAdbPath = async (
!failure.outputTruncated &&
failure.collectionFailure !== true &&
failure.stdout === "" &&
failure.stderr === ""
recognizedProbeStderr(failure.stderr)
)
return ok(false);
return ok(completedAdbPathProbe(false, failure.stderr));
return err(
new AnalysisProtocolError(error.message, {
cause: error.cause,
+22
View File
@@ -40,6 +40,28 @@ export const isSuccessfulDaemonStartNotice = (line: string): boolean =>
line === "* daemon started successfully" ||
line === "* daemon started successfully *";
/** Host-side startup line, shared by failure and successful probe parsing. */
export const isDaemonStartingNotice = (line: string): boolean =>
/^\* daemon not running(?:; starting now at [^\r\n]*|\. starting it now on port \d+ \*)$/u.test(
line,
);
/** No diagnostics beyond a completed, recognized startup sequence. */
export const hasOnlySuccessfulAdbStartupNotices = (output: string): boolean => {
let started = false;
for (let offset = 0; offset < output.length;) {
const newline = output.indexOf("\n", offset);
const line = normalizeAdbOutputLine(
output.slice(offset, newline === -1 ? output.length : newline),
);
if (isSuccessfulDaemonStartNotice(line)) started = true;
else if (isDaemonStartingNotice(line)) started = false;
else return false;
offset = newline === -1 ? output.length : newline + 1;
}
return started;
};
const metadataValue = (remainder: string, key: string): string | null => {
const match = new RegExp(`(?:^|\\s)${key}:(\\S+)`, "u").exec(remainder);
return match === null ? null : match[1]!;
+16 -3
View File
@@ -2407,7 +2407,7 @@ export class AdbProvider {
signal,
);
if (!directoryProbe.ok) return directoryProbe;
const devicePath = directoryProbe.value
const devicePath = directoryProbe.value.matches
? `${input.device_path}${input.device_path.endsWith("/") ? "" : "/"}${basename(localPath)}`
: input.device_path;
const existsProbe = await probeAdbPathExistence(
@@ -2419,7 +2419,7 @@ export class AdbProvider {
signal,
);
if (!existsProbe.ok) return existsProbe;
const deviceExists = existsProbe.value;
const deviceExists = existsProbe.value.matches;
if (deviceExists && !input.overwrite)
return err(
new AnalysisOutputError(
@@ -2440,6 +2440,17 @@ export class AdbProvider {
signal,
);
if (!pushed.ok) return pushed;
const observedProbeStartup =
directoryProbe.value.capturedOutput.stderr !== "" ||
existsProbe.value.capturedOutput.stderr !== "";
const probeOutput = observedProbeStartup
? {
device_path_probes: {
directory: { ...directoryProbe.value.capturedOutput },
existence: { ...existsProbe.value.capturedOutput },
},
}
: undefined;
const receipt = (
deviceDigest: AdbDeviceDigest,
rawResult?: {
@@ -2463,7 +2474,9 @@ export class AdbProvider {
},
provider,
{
...(rawResult === undefined ? {} : { rawResult }),
...(rawResult === undefined && probeOutput === undefined
? {}
: { rawResult: { ...rawResult, ...probeOutput } }),
limitations: [
"Push writes a private snapshot of the caller's file to the device",
...(deviceDigest.device_digest_source === "unavailable"
+131
View File
@@ -0,0 +1,131 @@
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it, onTestFinished, vi } from "vitest";
import { execFileBytes, execFileOutput } from "../process/ExecFileOutput.js";
import { AdbProvider } from "./AdbProvider.js";
vi.mock("../process/ExecFileOutput.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../process/ExecFileOutput.js")>()),
execFileBytes: vi.fn(),
execFileOutput: vi.fn(),
}));
const startup =
"* daemon not running; starting now at tcp:5037\n* daemon started successfully\n";
it.each([false, true])(
"retains startup probe evidence alongside digest observations (recognized=%s)",
async (recognized) => {
vi.clearAllMocks();
const root = await mkdtemp(join(tmpdir(), "rea-adb-probe-evidence-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const source = join(root, "input.bin");
const bytes = Buffer.from("caller-owned bytes");
await writeFile(source, bytes);
const digest = createHash("sha256").update(bytes).digest("hex");
const provider = new AdbProvider({
environment: { REA_ADB_PATH: process.execPath },
});
onTestFinished(() => provider.close());
vi.mocked(execFileOutput).mockImplementation(async (_binary, args) => {
expect(args).toEqual(["version"]);
const stdout =
"Android Debug Bridge version 1.0.41\nVersion 37.0.0-fixture\n";
return {
stdout,
stderr: "",
stdoutBytes: Buffer.byteLength(stdout),
stderrBytes: 0,
};
});
const digestOutput = recognized
? `${digest} /sdcard/receiver/input.bin\n`
: "unrecognized sha256sum fixture output\n";
const commands: string[][] = [];
let staged: string | undefined;
vi.mocked(execFileBytes).mockImplementation(async (_binary, args) => {
const command = [...args];
commands.push(command);
expect(command.slice(0, 2)).toEqual(["-s", "device"]);
if (
command[2] === "shell" &&
command[3] === "test" &&
command[4] === "-d"
)
return { stdout: Buffer.alloc(0), stderr: Buffer.from(startup) };
if (
command[2] === "shell" &&
command[3] === "test" &&
command[4] === "-e"
)
throw Object.assign(new Error("absent"), {
code: 1,
signal: null,
killed: false,
stdout: "",
stderr: startup,
});
if (command[2] === "push") {
staged = command[3];
expect(staged).not.toBe(source);
expect((await readFile(staged!)).equals(bytes)).toBe(true);
return {
stdout: Buffer.from("1 file pushed\n"),
stderr: Buffer.alloc(0),
};
}
if (command[2] === "shell" && command[3] === "sha256sum")
return { stdout: Buffer.from(digestOutput), stderr: Buffer.alloc(0) };
throw new Error("Unexpected adb fixture command");
});
const result = await provider.execute({
operation: "push_adb_file",
input: {
serial: "device",
local_path: source,
device_path: "/sdcard/receiver",
overwrite: false,
},
});
if (!result.ok) throw result.error;
expect(result.value.result).toMatchObject({
sha256: digest,
device_sha256: recognized ? digest : null,
overwritten: false,
device_digest_source: recognized ? "device_sha256sum" : "unavailable",
});
expect(result.value.rawResult).toEqual({
device_path_probes: {
directory: {
stdout: "",
stderr: startup,
stdout_bytes: 0,
stderr_bytes: Buffer.byteLength(startup),
exit_code: 0,
signal: null,
truncated: false,
},
existence: {
stdout: "",
stderr: startup,
stdout_bytes: 0,
stderr_bytes: Buffer.byteLength(startup),
exit_code: 1,
signal: null,
truncated: false,
},
},
...(recognized
? {}
: { device_sha256sum: { stdout: digestOutput, stderr: "" } }),
});
expect(commands.map((command) => command.slice(2, 5))).toEqual([
["shell", "test", "-d"],
["shell", "test", "-e"],
["push", staged, "/sdcard/receiver"],
["shell", "sha256sum", "/sdcard/receiver/input.bin"],
]);
expect((await readFile(source)).equals(bytes)).toBe(true);
expect(staged).toBeDefined();
await expect(stat(staged!)).rejects.toMatchObject({ code: "ENOENT" });
},
);
@@ -32,6 +32,20 @@ const projectedDataAttributes = new Set([
const literalBoolean = (value: string | null): boolean | null =>
value === "true" ? true : value === "false" ? false : null;
/** Whether a filter names an action, which Android requires to keep an activity filter. */
const declaresAction = (filter: Element): boolean => {
for (let index = 0; index < filter.childNodes.length; index += 1) {
const member = filter.childNodes.item(index);
if (
isManifestElement(member) &&
member.nodeName === "action" &&
androidAttribute(member, "name") !== null
)
return true;
}
return false;
};
/** One decoded-manifest lifetime owns nested declarations and cumulative copy budgets. */
export const createAndroidManifestDetailsReader = (
source: string,
@@ -99,6 +113,7 @@ export const createAndroidManifestDetailsReader = (
let legacyValuesRemaining = limits.intentValues;
let legacyTruncated = false;
let filterCount = 0;
let actionFilter = false;
for (let index = 0; index < component.childNodes.length; index += 1) {
const child = component.childNodes.item(index);
if (!isManifestElement(child)) continue;
@@ -111,6 +126,8 @@ export const createAndroidManifestDetailsReader = (
continue;
}
if (child.nodeName !== "intent-filter") continue;
// Checked past the filter bound too; one action-bearing filter suffices.
actionFilter ||= declaresAction(child);
const filterIndex = filterCount++;
if (filterIndex >= limits.intentFilters) {
partial = true;
@@ -237,6 +254,7 @@ export const createAndroidManifestDetailsReader = (
actions,
categories,
filterCount,
actionFilter,
legacyTruncated,
partial,
detailLimited,
+10 -3
View File
@@ -454,11 +454,18 @@ export const parseAndroidManifestComponents = (
: null;
const exportedNonliteral =
declaredExported !== null && exportedLiteral === null;
// Android discards an activity, alias or receiver filter that declares
// no action, while a service keeps it. Only kept filters select the
// export default and the API 31 explicit-export requirement.
const keptFilters =
component.nodeName === "service"
? details.filterCount > 0
: details.actionFilter;
const explicitExportRequired =
declaredExported === null &&
targetSdkLevel !== null &&
targetSdkLevel >= 31 &&
details.filterCount > 0 &&
keptFilters &&
(component.nodeName === "activity" ||
component.nodeName === "activity-alias" ||
component.nodeName === "service" ||
@@ -466,7 +473,7 @@ export const parseAndroidManifestComponents = (
const filteredExportUnknown =
declaredExported === null &&
targetSdkLevel === null &&
details.filterCount > 0 &&
keptFilters &&
(component.nodeName === "activity" ||
component.nodeName === "activity-alias" ||
component.nodeName === "service" ||
@@ -491,7 +498,7 @@ export const parseAndroidManifestComponents = (
? null
: component.nodeName === "provider"
? providerDefault
: details.filterCount > 0,
: keptFilters,
permission,
permission_source: effectivePermissionSource,
intent_filter_count: details.filterCount,
+2
View File
@@ -4,6 +4,7 @@ import {
bindArtifactFileCleanup,
} from "../artifacts/ArtifactFileOperation.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
import {
@@ -99,6 +100,7 @@ const readFailurePrimary = (
if (owned?.reason === "path")
return invalidMapping(read, "invalid_value", owned.message, {
cause: owned,
...stableArtifactInputRecovery(owned),
});
if (primary instanceof Error && "code" in primary) {
if (primary.code === "EACCES" || primary.code === "EPERM")
+4 -2
View File
@@ -1,9 +1,11 @@
import type { RetainedResourceScope } from "../application/AnalysisProvider.js";
import type {
RetainedResourceScope,
ProviderAvailability,
} from "../application/AnalysisProvider.js";
import type {
DoctorProviderCheck,
DoctorProviderInspection,
} from "../application/Doctor.js";
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
import type { JsonValue } from "../domain/jsonValue.js";
import {
inspectJadxAvailability,
+6 -3
View File
@@ -1,5 +1,6 @@
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.js";
import {
artifactFilePrimaryFacts,
type ArtifactFilePrimaryFacts,
@@ -230,9 +231,11 @@ const artifactReaderFailure = (
},
);
case "path":
return new AnalysisInputError(OPERATION, { cause }, [
{ path: ["path"], reason: "invalid_format", message: cause.message },
]);
return new AnalysisInputError(
OPERATION,
{ cause, ...stableArtifactInputRecovery(cause) },
[{ path: ["path"], reason: "invalid_format", message: cause.message }],
);
default:
return adapterFailure(cause, path);
}
@@ -8,6 +8,7 @@ import {
import {
createAnalysisExecution,
type AnalysisOperationPort,
type AnalysisExecution,
} from "./AnalysisProvider.js";
import {
AnalysisCapabilityUnavailableError,
@@ -21,7 +22,6 @@ import {
primaryAnalysisError,
type AnalysisError,
} from "../domain/analysisErrorBase.js";
import type { AnalysisExecution } from "./AnalysisProvider.js";
import type { Result } from "../domain/result.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { err, ok } from "../domain/result.js";
+42 -2
View File
@@ -6,6 +6,7 @@ import type {
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisInputError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
@@ -150,8 +151,12 @@ export const inspectAddressContext = async (
const procedure: Facet = resolution?.ok
? { state: "available", value: resolution.value }
: unavailableFacet(resolution?.error, operations[1]);
const comment = facet(results[2], operations[2]);
const inlineComment = facet(results[3], operations[3]);
const entry =
resolution?.ok && resolution.value.found
? resolution.value.procedure.address
: undefined;
const comment = commentFacet(results[2], operations[2], entry);
const inlineComment = commentFacet(results[3], operations[3], entry);
const bookmarks = facet(results[4], operations[4]);
return ok({
address: input.address,
@@ -170,6 +175,28 @@ export const inspectAddressContext = async (
});
};
const commentFacet = (
result: Result<AnalysisExecution, AnalysisError> | undefined,
operation: "comment" | "inline_comment",
entry: string | undefined,
): Facet => {
if (
result?.ok === false &&
result.error instanceof AnalysisCapabilityUnavailableError &&
!result.error.retryable &&
result.error.remediationAction === undefined &&
result.error.continuingCallerGuidance?.remediationAction === undefined &&
entry !== undefined
) {
return {
state: "unavailable",
reason: result.error.message,
remediation: `If analyze_function is available for this same document, call it with ${JSON.stringify({ procedure: entry })}; use its summary's parent_evidence_id with inspect_analysis_view's native comments facet. Check facet availability, pagination, and the exact requested address; an omitted comment does not establish absence.`,
};
}
return facet(result, operation);
};
const facet = (
result: Result<AnalysisExecution, AnalysisError> | undefined,
operation: AnalysisOperation,
@@ -182,6 +209,19 @@ const unavailableFacet = (
error: AnalysisError | undefined,
operation: AnalysisOperation,
): Facet => {
if (error instanceof AnalysisCapabilityUnavailableError) {
const projected = projectAnalysisError(error);
return {
state: "unavailable",
reason: error.userMessage ?? error.message,
remediation:
error.retryable ||
error.remediationAction !== undefined ||
error.continuingCallerGuidance?.remediationAction !== undefined
? projected.remediation.action
: "Review provider availability in binary_session with detail complete and select another supported provider if appropriate. After switching, if a name is known and search_procedures is available, use it to find candidates and verify their identity and coordinates.",
};
}
if (error instanceof AnalysisInputError) {
const projected = projectAnalysisError(error);
if (projected.code === "invalid_request")
+25 -1
View File
@@ -18,6 +18,10 @@ import type {
EvidenceSubjectTarget,
} from "../domain/evidence.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import {
analysisSourceDescriptorSchema,
type AnalysisSourceDescriptor,
} from "../domain/analysisSource.js";
import type { ProgressReporter } from "./ProgressReporter.js";
import type { ProviderRejectionCode } from "../domain/providerSelection.js";
import type { ProcessLineageObservation } from "../process/ProcessOwnership.js";
@@ -30,8 +34,12 @@ import {
export interface ExecutionOptions {
/** Ephemeral state shared only by one composed workflow. */
readonly workScope?: AnalysisWorkScope;
/** A single CLI answer bound to its isolated target, not a persistent default document. */
readonly snapshotContext?: "one-shot-target";
readonly signal?: AbortSignal;
readonly progress?: ProgressReporter;
/** The composed workflow will cite this observation and needs its Evidence. */
readonly retainEvidence?: true;
/**
* Set when a procedure selector came from provider output, such as a
* revisited callee, rather than from the caller. A provider-derived
@@ -56,6 +64,12 @@ export type AnalysisOperation =
/** Atomic successful provider observation with exact producing provenance. */
export interface AnalysisExecution extends AnalysisObservation {
readonly provider: ProviderIdentity;
/**
* Compact provider-owned facts for this execution. This is not an Evidence
* record and is not restored by snapshot replay; consumers must treat its
* absence as unknown.
*/
readonly sourceDescriptor?: AnalysisSourceDescriptor;
/** Session-issued identity of the committed record consumed by this execution. */
readonly evidenceId?: string;
/** Result-specific veto of snapshot replay; Evidence remains retainable. */
@@ -87,6 +101,7 @@ export interface AnalysisExecution extends AnalysisObservation {
interface CreateAnalysisExecutionOptions {
readonly rawResult?: unknown;
readonly sourceDescriptor?: AnalysisSourceDescriptor;
readonly analysisProfile?: AnalysisProfileCommitment;
readonly cachePolicy?: "live";
readonly limitations?: readonly string[];
@@ -129,6 +144,13 @@ export function createAnalysisExecution(
? null
: jsonValueSchema.parse(options.rawResult),
provider,
...(options.sourceDescriptor === undefined
? {}
: {
sourceDescriptor: analysisSourceDescriptorSchema.parse(
options.sourceDescriptor,
),
}),
...(options.cachePolicy === undefined
? {}
: { cachePolicy: options.cachePolicy }),
@@ -286,8 +308,10 @@ type CapabilityAvailability =
readonly available: false;
readonly reason: string;
readonly availabilityCode?: ProviderRejectionCode;
/** Provider-owned explanation and recovery for an attempted operation. */
readonly userMessage?: string;
/**
* Repair instruction from the readiness owner that made this operation
* Recovery instruction from the owner that made this operation
* unavailable. Static capability facts omit it.
*/
readonly remediation?: string;
+40 -35
View File
@@ -85,6 +85,10 @@ import { hasDiskImageSignature } from "../domain/apple/diskImageFormat.js";
import { hasGodotPckHeader } from "../domain/godotPckFormat.js";
import { hasUImageMagic } from "../domain/uimageFormat.js";
import { hasUnityFsHeader } from "../domain/unityfsFormat.js";
import {
hasWxapkgEncryptedMagic,
hasWxapkgHeader,
} from "../domain/wxapkgFormat.js";
import { detectHexRecordEncoding } from "../domain/firmware/hexRecords.js";
import { hasUf2Block } from "../domain/firmware/uf2.js";
import {
@@ -189,6 +193,9 @@ export const parseBinaryTarget = async (
const { executable: path, infoPlist } = resolved.value;
const bundle =
infoPlist === undefined ? {} : { bundleInfoPlist: infoPlist };
// A bundle directory is selected for its executable, so the executable's
// own name classifies it; a selected file keeps the caller's spelling.
const classifiedAs = infoPlist === undefined ? candidate : path;
return withOwnedRegularFile<TargetResolution>(
path,
{
@@ -199,7 +206,13 @@ export const parseBinaryTarget = async (
try {
return await readOpenedBinaryTarget(
source,
{ path, sourcePath, selectedPath: candidate, ...bundle },
{
path,
sourcePath,
selectedPath: candidate,
classifiedAs,
...bundle,
},
options,
);
} catch (cause: unknown) {
@@ -279,13 +292,23 @@ const readOpenedBinaryTarget = async (
readonly path: string;
readonly sourcePath: string;
readonly selectedPath: string;
/** Spelling whose name supplies format hints: the selected file, or a bundle's executable. */
readonly classifiedAs: string;
readonly bundleInfoPlist?: string;
},
options: BinaryTargetResolverOptions,
): Promise<TargetResolution> => {
const { handle, initial: metadata } = source;
const { path, sourcePath, selectedPath, bundleInfoPlist } = selected;
const { path, sourcePath, selectedPath, classifiedAs, bundleInfoPlist } =
selected;
const bundle = bundleInfoPlist === undefined ? {} : { bundleInfoPlist };
// Every admitted target shares one identity; routes add their own facts.
const identity = async () => ({
path,
sourcePath,
...bundle,
sha256: await sha256Handle(handle, path, metadata, signal),
});
const hostArchitecture = options.hostArchitecture ?? process.arch;
const { targetKind, formatHint, signal, architecture, entryPoint } = options;
throwIfTargetResolutionCancelled(signal);
@@ -322,10 +345,7 @@ const readOpenedBinaryTarget = async (
}),
);
return ok({
path,
sourcePath,
...bundle,
sha256: await sha256Handle(handle, path, metadata, signal),
...(await identity()),
kind: "executable",
format: "raw-binary",
architecture: admitted.value.architecture,
@@ -373,10 +393,7 @@ const readOpenedBinaryTarget = async (
}),
);
return ok({
path,
sourcePath,
...bundle,
sha256: await sha256Handle(handle, path, metadata, signal),
...(await identity()),
kind: "executable",
format: "gamecube-dol",
architecture: admitted.value.architecture,
@@ -414,10 +431,8 @@ const readOpenedBinaryTarget = async (
),
);
return ok({
path,
sourcePath,
...(await identity()),
artifactSelectedPath: selectedPath,
sha256: await sha256Handle(handle, path, metadata, signal),
kind: "archive",
format: "esp-flash-image",
espFlash: {
@@ -444,10 +459,7 @@ const readOpenedBinaryTarget = async (
}),
);
return ok({
path,
sourcePath,
...bundle,
sha256: await sha256Handle(handle, path, metadata, signal),
...(await identity()),
kind: "executable",
format: "dos-com",
architecture: "x86",
@@ -456,11 +468,15 @@ const readOpenedBinaryTarget = async (
}
// Probe WASM content before suffix-only database routing; an explicit
// database selection keeps the caller's chosen interpretation.
const classificationPath = artifactNameForClassification(selectedPath, path);
const classificationPath = artifactNameForClassification(classifiedAs, path);
const artifactFormat =
targetKind === "database"
? undefined
: await detectArtifactFormat(classificationPath, source, signal, path);
const artifactIdentity = async () => ({
...(await identity()),
artifactSelectedPath: selectedPath,
});
if (
targetKind === "database" ||
(targetKind === undefined &&
@@ -469,19 +485,12 @@ const readOpenedBinaryTarget = async (
path.toLowerCase().endsWith(".hop"))
)
return ok({
path,
sourcePath,
sha256: await sha256Handle(handle, path, metadata, signal),
...(await identity()),
kind: "database",
format: "analysis-database",
});
if (artifactFormat !== undefined) {
const identity = {
path,
sourcePath,
artifactSelectedPath: selectedPath,
sha256: await sha256Handle(handle, path, metadata, signal),
};
const identity = await artifactIdentity();
if (artifactFormat === "esp-flash-image")
return ok({
...identity,
@@ -512,12 +521,9 @@ const readOpenedBinaryTarget = async (
)) !== undefined
)
return ok({
path,
sourcePath,
sha256: await sha256Handle(handle, path, metadata, signal),
...(await artifactIdentity()),
kind: "archive",
format: zipRootPackageFormat(classificationPath),
artifactSelectedPath: selectedPath,
});
const reason =
typeof detected.error === "string"
@@ -535,10 +541,7 @@ const readOpenedBinaryTarget = async (
);
}
return ok({
path,
sourcePath,
...bundle,
sha256: await sha256Handle(handle, path, metadata, signal),
...(await identity()),
kind: "executable",
...detected.value,
});
@@ -778,6 +781,7 @@ const detectArtifactFormat = async (
if (hasZipSignature(magic)) return zipRootPackageFormat(lower);
if (hasGodotPckHeader(magic)) return "godot-pck";
if (hasUnityFsHeader(magic)) return "unityfs";
if (hasWxapkgEncryptedMagic(magic) || hasWxapkgHeader(magic)) return "wxapkg";
if (hasUImageMagic(magic)) return "uimage";
const hexRecords = detectHexRecordEncoding(magic);
if (hexRecords !== undefined) return hexRecords;
@@ -884,6 +888,7 @@ const isArchiveFormat = (
"ines",
"godot-pck",
"unityfs",
"wxapkg",
"uimage",
"dmg",
"pkg",
@@ -196,6 +196,54 @@ it("reports the active resource session's dispatch policy without limiting indep
);
});
it("derives closed-session address and explicit-path APK availability from their contracts", () => {
expect(entry("inspect_address_context", status())).toMatchObject({
available: false,
reason: "target_required",
remediation: expect.any(String),
});
expect(entry("inspect_apk_signature", status())).toMatchObject({
available: true,
reason: "available",
remediation: null,
});
expect(
entry(
"inspect_address_context",
status({
open: true,
kind: "executable",
capabilities: [
{
operation: "inspect_address_context",
available: true,
reason: null,
},
],
}),
),
).toMatchObject({ available: true, reason: "available" });
expect(entry("inspect_apk_signature", status({ open: true }))).toMatchObject({
available: true,
reason: "available",
});
for (const kind of ["artifact", "archive"] as const) {
expect(
entry("inspect_address_context", status({ open: true, kind })),
).toMatchObject({
available: false,
reason: "target_unsupported",
remediation: expect.stringContaining("explicit document"),
});
expect(
entry("inspect_apk_signature", status({ open: true, kind })),
).toMatchObject({
available: true,
reason: "available",
});
}
});
describe("ADB and Apktool host availability", () => {
it("uses only their selected owner probes", () => {
const ready = {
@@ -513,6 +561,31 @@ describe("compact tool availability", () => {
});
});
it("reports target restrictions for navigation modes even when providers are declared", () => {
const capabilities = [
"current_document",
"current_address",
"resolve_containing_procedure",
].map((operation) => ({ operation, available: true, reason: null }));
for (const kind of ["artifact", "archive"] as const) {
const navigation = entry(
"get_navigation_context",
status({ open: true, kind, capabilities }),
);
expect(navigation).toMatchObject({
available: false,
reason: "target_unsupported",
default_mode_available: false,
modes: [
{ name: "current_selection", available: false },
{ name: "explicit_document", available: false },
],
});
for (const mode of navigation.modes ?? [])
expect(mode.remediation).toBe(navigation.remediation);
}
});
describe("partial navigation availability", () => {
it("requires a target before diagnosing navigation provider modes", () => {
expect(entry("get_navigation_context", status())).toMatchObject({
+29 -9
View File
@@ -416,12 +416,25 @@ const availabilityFor = (context: AvailabilityContext): Availability => {
)
return { reason: "available", remediation: null };
if (context.name === "get_navigation_context") {
const target = targetAvailability(context);
if (target !== null) {
return {
...target,
defaultModeAvailable: false,
modes: NAVIGATION_CONTEXT_MODES.map((mode) => ({
name: mode.name,
available: false,
missing_operations: [...mode.requirements],
remediation: target.remediation,
})),
};
}
const modes = availabilityForModes(
NAVIGATION_CONTEXT_MODES,
"current_selection",
context.descriptors,
);
return { ...modes, ...targetAvailability(context) };
return modes;
}
const javascriptApplication = javascriptApplicationAvailability(context);
if (javascriptApplication !== null) return javascriptApplication;
@@ -467,11 +480,10 @@ const javascriptApplicationAvailability = ({
: null;
};
const workflowAvailabilityFor = ({
name,
kind,
policy,
}: AvailabilityContext): Availability | null => {
const workflowAvailabilityFor = (
context: AvailabilityContext,
): Availability | null => {
const { name, kind, policy } = context;
if (name === "start_gdb_session" || name === "start_rizin_debug_session")
return hostRestrictedAvailability(
policy,
@@ -681,7 +693,10 @@ const workflowAvailabilityFor = ({
remediation:
"Start REA with its JavaScript runtime provider available.",
};
if (kind === "session") return { reason: "available", remediation: null };
if (kind === "session")
return (
targetAvailability(context) ?? { reason: "available", remediation: null }
);
return null;
};
@@ -822,12 +837,17 @@ const targetAvailability = ({
targetKind !== undefined &&
targetKind !== "executable" &&
targetKind !== "database" &&
(kind === "official-proxy" || kind === "enhanced")
(kind === "official-proxy" ||
kind === "enhanced" ||
name === "get_navigation_context" ||
name === "inspect_address_context")
)
return {
reason: "target_unsupported",
remediation:
"Inventory or extract a native executable, then call open_binary on that executable.",
name === "inspect_address_context"
? "Inventory or extract a native executable, then call open_binary on that executable. An explicit document can still be queried, but its facets may be unavailable."
: "Inventory or extract a native executable, then call open_binary on that executable.",
};
if (
targetOpen &&
@@ -152,9 +152,10 @@ const parseDocument = (
// Pi loads `mcp.json` with a strict `JSON.parse` and no BOM tolerance, so
// comments, trailing commas, a BOM, or an empty existing file must be
// rejected instead of registering into a document the client cannot load.
// Kimi also reads mcp.json with JSON.parse and an empty-file fallback.
if (format === "pi" || format === "kimi") {
if (format === "kimi" && text.trim().length === 0) return {};
// Kimi and LM Studio also read mcp.json with JSON.parse; an empty file holds
// nothing to preserve.
if (format === "pi" || format === "kimi" || format === "lm_studio") {
if (format !== "pi" && 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,
@@ -1748,7 +1749,8 @@ export const clientRegistrationEntry = (
: { environment: { ...environment } }),
};
case "kimi":
// Kimi infers stdio from command; it uses `transport`, not `type`.
case "lm_studio":
// Both infer stdio from command; Kimi uses `transport`, not `type`.
return {
command: executable,
args,
+10 -6
View File
@@ -28,6 +28,7 @@ import {
import {
clientEvidencePaths,
clientOwnershipEvidencePaths,
compactInputSchemaByDefault,
manualRegistrationRemediation,
supportedClients,
} from "./SupportedClients.js";
@@ -279,12 +280,14 @@ export const readClientRegistrationInspections = async (
}
const registration = parsedRegistration.value;
const registrationEnvironment =
client.name === "kimi" || client.name === "codex"
compactInputSchemaByDefault(client) || client.name === "codex"
? await clientRegistrationEnvironmentDetailsWithBudget(
parsed,
{},
"effective",
options.resolveProviderRequestBudget,
client.name === "kimi" || client.name === "codex"
? options.resolveProviderRequestBudget
: undefined,
options.platform,
)
: undefined;
@@ -321,7 +324,7 @@ export const readClientRegistrationInspections = async (
command,
commandFacts,
platform: options.platform ?? process.platform,
kimiInputSchemaProfileSelected:
inputSchemaProfileSelected:
registrationEnvironment?.ok === true &&
snapshotEnvironment(
registrationEnvironment.value.values,
@@ -449,14 +452,14 @@ const registrationAligned = ({
command,
commandFacts,
platform,
kimiInputSchemaProfileSelected,
inputSchemaProfileSelected,
}: {
readonly registration: ClientRegistration;
readonly client: SetupClient;
readonly command: readonly string[];
readonly commandFacts: ClientRegistrationCommandFacts;
readonly platform: NodeJS.Platform;
readonly kimiInputSchemaProfileSelected: boolean;
readonly inputSchemaProfileSelected: boolean;
}): "aligned" | "stale" | "unknown" => {
const { identity } = commandFacts;
if (identity === "unknown") return "unknown";
@@ -478,7 +481,8 @@ const registrationAligned = ({
registration.toolTimeoutMs === undefined)
)
return "stale";
if (client.name === "kimi" && !kimiInputSchemaProfileSelected) return "stale";
if (compactInputSchemaByDefault(client) && !inputSchemaProfileSelected)
return "stale";
// A Harness registration only connects as the rea stdio server; another
// server name or transport leaves the session without REA tools.
if (
+75 -8
View File
@@ -17,8 +17,15 @@ import type { ResolvedSessionOpen } from "./binary/BinarySessionOpen.js";
import { silentLogger } from "../logger.js";
import type { Logger } from "pino";
import { withEvidenceCallerInputPath } from "../domain/evidence.js";
import { createImmutableEvidenceBundle } from "../domain/evidenceBundle.js";
import { retainAnalysisResultSources } from "./investigation/recordEvidenceSources.js";
import {
createImmutableEvidenceBundle,
type EvidenceBundle,
} from "../domain/evidenceBundle.js";
import {
retainAnalysisResultSources,
type AnalysisResultWithSources,
} from "./investigation/recordEvidenceSources.js";
import { resolveInvestigationEvidenceClosure } from "./investigation/InvestigationEvidence.js";
import type { Evidence } from "../domain/evidence.js";
import type { NativeToolName } from "../contracts/native/nativeToolContracts.js";
import type { EnhancedToolName } from "../contracts/enhancedInputs.js";
@@ -139,13 +146,22 @@ export const runDirectAnalysis = async (
readonly architecture?: BinaryArchitecture;
readonly entryPoint?: string;
readonly espFlash?: EspFlashSelection;
readonly includeEvidenceSources?: boolean;
/** Capture the exact session Evidence available to a one-shot CLI adapter. */
readonly onEvidenceBundle?: (bundle: EvidenceBundle) => void;
} = {},
): Promise<Result<Evidence, JsonValue>> =>
): Promise<AnalysisResultWithSources<Evidence, JsonValue>> =>
withProcessCancellation(options.signal, (signal) =>
runAnalysis(dependencies, path, tool, arguments_, {
logger: options.logger ?? silentLogger,
snapshotPath: options.snapshotPath,
signal,
...(options.includeEvidenceSources === true
? { includeEvidenceSources: true }
: {}),
...(options.onEvidenceBundle === undefined
? {}
: { onEvidenceBundle: options.onEvidenceBundle }),
...targetSelection(options),
...(options.providerId === undefined
? {}
@@ -364,14 +380,16 @@ const runAnalysis = async (
readonly snapshotPath: string | undefined;
readonly signal: AbortSignal;
readonly evidenceParameters?: Readonly<Record<string, JsonValue>>;
readonly onEvidenceBundle?: (bundle: EvidenceBundle) => void;
readonly createSession?: () => BinarySession | Promise<BinarySession>;
readonly providerId?: AnalysisProviderSelector;
readonly formatHint?: ExecutableFormatHint;
readonly architecture?: BinaryArchitecture;
readonly entryPoint?: string;
readonly espFlash?: EspFlashSelection;
readonly includeEvidenceSources?: boolean;
},
): Promise<Result<Evidence, JsonValue>> => {
): Promise<AnalysisResultWithSources<Evidence, JsonValue>> => {
const { logger, signal, snapshotPath } = options;
const evidenceParameters = options.evidenceParameters ?? arguments_;
let session: BinarySession;
@@ -387,6 +405,29 @@ const runAnalysis = async (
)(config.value, logger);
}
let publishedSnapshot: PublishedEvidenceFile | undefined;
let sourceEvidence: readonly Evidence[] | undefined;
const captureSources = (
evidence: Evidence,
supplied: readonly Evidence[] = [],
): Result<null, AnalysisError> => {
if (options.includeEvidenceSources !== true) return ok(null);
const known = new Map(
supplied.map((record) => [record.evidence_id, record]),
);
const closure = resolveInvestigationEvidenceClosure(
{
evidenceForAnalysis: (id) => {
const record = known.get(id) ?? session.evidenceForAnalysis(id);
if (record !== undefined) known.set(id, record);
return record;
},
},
evidence.evidence_links,
);
if (!closure.ok) return closure;
sourceEvidence = closure.value.map((id) => known.get(id) as Evidence);
return ok(null);
};
const execute = async (): Promise<Result<Evidence, AnalysisError>> => {
let isDirectory = false;
if (BUNDLE_RESOURCE_OPERATIONS.has(tool)) {
@@ -458,8 +499,17 @@ const runAnalysis = async (
: routeProviderIdentity(route, tool),
evidenceProfile,
});
if (cached !== undefined)
return ok(withEvidenceCallerInputPath(cached, path));
if (cached !== undefined) {
const evidence = withEvidenceCallerInputPath(cached, path);
const sources = captureSources(
evidence,
snapshot.evidence_bundle.records,
);
if (!sources.ok)
return err(sources.error.retainPartialObservation(evidence));
options.onEvidenceBundle?.(snapshot.evidence_bundle);
return ok(evidence);
}
}
}
const openOptions = {
@@ -484,6 +534,7 @@ const runAnalysis = async (
evidenceParameters,
signal,
evidenceProfile,
saveSnapshot: snapshotPath !== undefined,
});
if (!executed.ok) return executed;
const { cachePolicy } = executed.value;
@@ -496,12 +547,16 @@ const runAnalysis = async (
);
if (!recorded.ok)
return err(recorded.error.retainPartialObservation(evidence));
const sources = captureSources(evidence);
if (!sources.ok)
return err(sources.error.retainPartialObservation(evidence));
if (isWorkflowEvidenceTool(tool)) {
const unknowns = recordWorkflowUnknowns({
name: tool,
result: evidence.normalized_result,
evidenceId: evidence.evidence_id,
recordUnknown: (unknown) => session.recordUnknown(unknown),
evidenceRetained: true,
});
if (!unknowns.ok)
return err(unknowns.error.retainPartialObservation(evidence));
@@ -551,6 +606,7 @@ const runAnalysis = async (
return err(written.error.retainPartialObservation(evidence));
}
}
options.onEvidenceBundle?.(session.exportEvidenceBundle());
return ok(evidence);
};
const outcome = await withSessionCleanup<{
@@ -579,7 +635,12 @@ const runAnalysis = async (
directAnalysisCleanupFailure(result, outcome.cleanupError, path),
);
}
return result.ok ? ok(result.value) : err(cliError(result.error, path));
return result.ok
? {
...ok(result.value),
...(sourceEvidence === undefined ? {} : { sourceEvidence }),
}
: err(cliError(result.error, path));
};
const executeAnalysisTool = async (input: {
@@ -594,6 +655,7 @@ const executeAnalysisTool = async (input: {
readonly evidenceParameters: Readonly<Record<string, JsonValue>>;
readonly signal: AbortSignal;
readonly evidenceProfile: AnalysisProfileCommitment | undefined;
readonly saveSnapshot: boolean;
}): Promise<
Result<
{
@@ -658,7 +720,12 @@ const executeAnalysisTool = async (input: {
: { cachePolicy: result.value.cachePolicy }),
});
}
let result = await session.execute(tool, input.arguments, { signal });
let result = await session.execute(tool, input.arguments, {
signal,
...(input.saveSnapshot && tool === "procedure_pseudo_code"
? { snapshotContext: "one-shot-target" as const }
: {}),
});
if (
tool === "xrefs" &&
typeof input.arguments.address === "string" &&
+4 -13
View File
@@ -3,7 +3,7 @@ import type { z } from "zod";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { TraceContainingProcedure } from "../domain/native/nativeObservation.js";
import type { LiteralFeatureReference } from "../domain/native/literalFeatureTrace.js";
import { err, type Result } from "../domain/result.js";
import {
enhancedInputSchemas,
@@ -18,7 +18,8 @@ export interface PartialEnhancedFailure {
readonly partialResult: JsonValue;
}
export type EnhancedOutcome =
Result<JsonValue, AnalysisError> | PartialEnhancedFailure;
| Result<JsonValue, AnalysisError>
| PartialEnhancedFailure;
export type EnhancedResult = Promise<EnhancedOutcome>;
/** Keep the stopping failure primary while retaining the admitted result. */
@@ -26,17 +27,7 @@ export const failedWithPartialResult = (
error: AnalysisError,
partialResult: JsonValue,
): PartialEnhancedFailure => ({ ok: false, error, partialResult });
export type TraceReference = {
readonly target_address: string;
readonly source_address: string;
readonly containing_procedure: TraceContainingProcedure;
/** Omission means the provider supplied no classification for this site. */
readonly source_classifications?: {
readonly kind: "stored_pointer";
readonly provenance: string;
readonly width: number;
}[];
};
export type TraceReference = LiteralFeatureReference;
/** One enhanced call whose input was parsed at its owning adapter boundary. */
export type ValidatedEnhancedCall = {
+20 -51
View File
@@ -25,7 +25,6 @@ import {
} from "../contracts/enhancedInputs.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
@@ -91,48 +90,14 @@ type BatchReadyItem = {
readonly providerProcedure: string;
};
/**
* Guidance for a batch item whose provider returned null pseudocode, by
* whether that observation reported caveats for the item to carry.
*/
export const NULL_PSEUDOCODE_GUIDANCE = {
reported: {
userMessage:
"The provider returned no pseudocode for this function; this item's limitations hold that observation's caveats, including any reason the provider reported.",
remediationAction:
"Read this item's limitations for the provider's reason before retrying; another provider may decompile this entry.",
},
none: {
userMessage:
"The provider returned no pseudocode for this function and reported no caveat or reason for it.",
remediationAction:
"The provider gave no further explanation; another provider may decompile this entry.",
},
} as const;
/**
* The item error for a provider answer without usable text. Null is the
* provider's observation that it has no pseudocode for this function; empty
* or non-text pseudocode is unreadable output.
*/
const missingPseudocodeError = (
providerId: string,
pseudocode: JsonValue,
caveats: keyof typeof NULL_PSEUDOCODE_GUIDANCE,
): AnalysisError =>
pseudocode === null
? new AnalysisCapabilityUnavailableError(
providerId,
"procedure_pseudo_code",
"provider returned null pseudocode",
NULL_PSEUDOCODE_GUIDANCE[caveats],
)
: new AnalysisOutputError(
"procedure_pseudo_code",
typeof pseudocode === "string"
? "provider returned empty pseudocode"
: "provider returned non-text pseudocode",
);
/** Empty and non-text answers are malformed; null is a separate observation. */
const missingPseudocodeError = (pseudocode: JsonValue): AnalysisError =>
new AnalysisOutputError(
"procedure_pseudo_code",
typeof pseudocode === "string"
? "provider returned empty pseudocode"
: "provider returned non-text pseudocode",
);
const isAnalysisCancellation = (
error: AnalysisError,
@@ -539,7 +504,7 @@ export class EnhancedTools {
});
continue;
}
const { result: pseudocode, provider } = observed.value;
const { result: pseudocode } = observed.value;
if (typeof pseudocode === "string" && pseudocode.length > 0) {
items.push({ address, procedure, status: "ok", pseudocode });
continue;
@@ -547,17 +512,21 @@ export class EnhancedTools {
// Only this observation can explain its missing text, so its caveats
// stay with the item; the batch Evidence still aggregates them.
const limitations = [...new Set(observed.value.limitations)];
if (pseudocode === null) {
items.push({
address,
procedure,
status: "unavailable",
reason: "no_pseudocode",
...(limitations.length === 0 ? {} : { limitations }),
});
continue;
}
items.push({
address,
procedure,
status: "error",
error: projectAnalysisError(
missingPseudocodeError(
provider.id,
pseudocode,
limitations.length === 0 ? "none" : "reported",
),
),
error: projectAnalysisError(missingPseudocodeError(pseudocode)),
...(limitations.length === 0 ? {} : { limitations }),
});
}
+16
View File
@@ -4,6 +4,7 @@ import { jsonValueValidationIssue } from "../domain/jsonValue.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { requireAuthenticationHeadroom } from "./JsonAuthenticationHeadroom.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { resemblesAnalysisSnapshot } from "../domain/analysisSnapshot.js";
import { describeValidationFailure } from "../domain/validationFailure.js";
import {
@@ -157,6 +158,21 @@ export const authenticateEvidenceBundleValue = (
userMessage: failure.message,
}),
);
if (
operation === "import_evidence_bundle" &&
typeof value === "object" &&
value !== null &&
!Object.hasOwn(value, "records") &&
resemblesAnalysisSnapshot(value)
)
return err(
new EvidenceFileError("read", "wrong-kind", {
path: absoluteCallerPath(path),
cause: failure.cause,
userMessage:
"This JSON appears to be an analysis snapshot rather than an Evidence bundle. Its snapshot content has not been validated. To restore it, use a matching target, provider and analysis profile: call open_binary with snapshot_path set to this file, or pass --snapshot to a compatible CLI analysis command (which loads and updates the snapshot). Alternatively, supply a bundle from export_evidence_bundle.",
}),
);
return err(
new EvidenceIntegrityError("Evidence bundle validation failed", {
cause: failure.cause,
+2 -1
View File
@@ -67,6 +67,7 @@ import type {
SetupProviderEnvironment,
} from "./SetupTypes.js";
import {
compactInputSchemaByDefault,
registrationWriteBlocker,
type SetupClient,
} from "./SupportedClients.js";
@@ -936,7 +937,7 @@ const clientConfigurationDesired = async (
);
if (!environment.ok) throw environment.error;
const registrationEnvironment =
client.name === "kimi" &&
compactInputSchemaByDefault(client) &&
snapshotEnvironment(environment.value).REA_MCP_INPUT_SCHEMA_PROFILE ===
undefined
? {
+42 -1
View File
@@ -1,6 +1,6 @@
import { dirname, isAbsolute, join, resolve, win32 } from "node:path";
import { fileURLToPath } from "node:url";
import { lstatSync } from "node:fs";
import { existsSync, lstatSync, readFileSync } from "node:fs";
import { err, ok, type Result } from "../domain/result.js";
import {
HermesClientPathError,
@@ -59,6 +59,7 @@ export interface SetupClient {
| "omp"
| "pi"
| "kimi"
| "lm_studio"
| "hermes"
| "zcode_fallback"
| "zcode"
@@ -193,6 +194,40 @@ const codexDirectory = ({ home, env }: ClientPathContext): string =>
const kimiDirectory = ({ home, env }: ClientPathContext): string =>
env.KIMI_CODE_HOME || join(home, ".kimi-code");
/** Clients registered with the compact input-schema profile unless one is selected. */
export const compactInputSchemaByDefault = ({
name,
}: Pick<SetupClient, "name">): boolean =>
name === "kimi" || name === "lm_studio";
/**
* LM Studio's findLMStudioHome, read-only: a home pointer file wins, then an
* existing ~/.cache/lm-studio, then ~/.lmstudio. Setup never writes the pointer.
*/
const lmStudioLocation = ({
home,
}: ClientPathContext): Result<ClientLocation, ClientPathFailure> => {
const pointerPath = join(home, ".lmstudio-home-pointer");
let directory: string;
try {
directory = readFileSync(pointerPath, "utf8").trim();
} catch (cause: unknown) {
if (!(cause instanceof Error && "code" in cause && cause.code === "ENOENT"))
return err({
path: pointerPath,
message: `${pointerPath} could not be read, so REA cannot establish which LM Studio home holds mcp.json: ${cause instanceof Error ? cause.message : String(cause)}. Repair access to that file, then rerun.`,
});
const cache = join(home, ".cache", "lm-studio");
directory = existsSync(cache) ? cache : join(home, ".lmstudio");
}
if (!isAbsolute(directory))
return err({
path: pointerPath,
message: `${pointerPath} does not hold an absolute LM Studio home path. Correct or remove that file, then rerun.`,
});
return ok({ configPath: join(directory, "mcp.json"), markerPath: directory });
};
const qoderDirectory = ({ home, env }: ClientPathContext): string =>
env.QODER_CONFIG_DIR || join(home, ".qoder");
@@ -652,6 +687,12 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
markerPath: [".workbuddy"],
format: "json",
},
{
name: "lm_studio",
displayName: "LM Studio",
location: lmStudioLocation,
format: "lm_studio",
},
{
name: "grok_build",
displayName: "Grok Build",
+6 -1
View File
@@ -13,6 +13,9 @@ export const workflowAnalysisProfile = (
createAnalysisProfile(REA_WORKFLOW_PROVIDER, {
upstream_analysis_profile: upstream,
source_limitations: "provider-operation-caveats-v1",
...(operation === "trace_native_values"
? { value_flow_absence: "recorded-versus-unavailable-v1" }
: {}),
...(operation === "binary_overview"
? { workflow_revision: 5 }
: operation === "batch_decompile"
@@ -21,5 +24,7 @@ export const workflowAnalysisProfile = (
? { workflow_revision: 8 }
: operation === "get_call_graph"
? { workflow_revision: 3 }
: { workflow_revision: 1 }),
: operation === "trace_native_values"
? { workflow_revision: 2 }
: { workflow_revision: 1 }),
});
+163 -1
View File
@@ -13,7 +13,11 @@ import {
createAnalysisExecution,
type AnalysisOperationPort,
} from "./AnalysisProvider.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import {
AnalysisCancelledError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { nativeApiBoundarySchema } from "../domain/native/nativeApiBoundary.js";
import { projectNativeApiInspection } from "./native/NativeApiInspection.js";
import { functionDossierSchema } from "../domain/native/functionDossier.js";
@@ -111,6 +115,99 @@ it.each([false, true])(
const provider = { id: "fixture", name: "Fixture", version: "1" };
it("retains requested live observations and links their original producer sources", async () => {
const upstream = createEvidence(undefined, provider, {
operation: "health",
parameters: {},
result: "source",
});
const result = await executeComposedWorkflow(
{
execute: async () =>
ok(
createAnalysisExecution("live observation", provider, {
cachePolicy: "live",
sourceEvidence: [upstream],
}),
),
},
async (observed) => {
const value = await observed.execute(
"analyze_function",
{ procedure: "0x1" },
{
retainEvidence: true,
},
);
return value.ok ? ok({ source: value.value.evidenceId ?? null }) : value;
},
);
if (!result.ok) throw result.error;
const source = result.sourceEvidence?.find(
(record) => record.operation === "analyze_function",
);
expect(source).toBeDefined();
expect(source?.evidence_links).toEqual([upstream.evidence_id]);
expect(result.value.result).toEqual({ source: source?.evidence_id });
expect(result.value.evidenceLinks).toContain(source?.evidence_id);
expect(result.value.cachePolicy).toBe("live");
});
it("keeps an existing session-issued source identity on replay", async () => {
const original = createEvidence(undefined, provider, {
operation: "analyze_function",
parameters: { procedure: "0x1" },
result: "original",
});
const result = await executeComposedWorkflow(
{
execute: async () =>
ok({
...createAnalysisExecution("original", provider, {
limitations: ["replayed"],
}),
evidenceId: original.evidence_id,
}),
},
async (observed) => {
const value = await observed.execute(
"analyze_function",
{ procedure: "0x1" },
{
retainEvidence: true,
},
);
return value.ok ? ok({ source: value.value.evidenceId ?? null }) : value;
},
);
if (!result.ok) throw result.error;
expect(result.value.result).toEqual({ source: original.evidence_id });
expect(result.value.evidenceLinks).toEqual([original.evidence_id]);
expect(result.sourceEvidence).toBeUndefined();
});
it("retains a completed requested source if the composed workflow is cancelled", async () => {
const result = await executeComposedWorkflow(
{
execute: async () => ok(createAnalysisExecution("observed", provider)),
},
async (observed) => {
await observed.execute(
"analyze_function",
{ procedure: "0x1" },
{
retainEvidence: true,
},
);
return err(new AnalysisCancelledError("trace_native_values"));
},
);
expect(result.ok).toBe(false);
expect(result.sourceEvidence?.map((record) => record.operation)).toEqual([
"analyze_function",
]);
});
it("keeps Evidence identity stable when dependent branches finish in opposite orders", async () => {
const run = async (order: readonly number[]) => {
const gate = () => {
@@ -578,6 +675,71 @@ describe("workflow residual question retention", () => {
});
});
it("reports a retained workflow and the committed prefix when Unknown publication fails", () => {
const records = new InvestigationRecords();
const result = { residual_unknowns: ["First?", "First?", "Second?"] };
const evidence = createWorkflowEvidence({
target: undefined,
operation: "trace_feature",
parameters: {},
result,
upstreamProfile: undefined,
upstreamLimitations: [],
});
expect(records.recordEvidence(evidence).ok).toBe(true);
const failure = new AnalysisResourceConstraintError(
"record_unknown",
"capacity",
"full",
null,
);
const outcome = recordWorkflowUnknowns({
name: "trace_feature",
result,
evidenceId: evidence.evidence_id,
evidenceRetained: true,
recordUnknown: (input) =>
input.question.startsWith("Second?")
? err(failure)
: records.recordUnknown(input),
});
expect(outcome.ok).toBe(false);
if (outcome.ok) throw new Error("expected failure");
const projected = projectAnalysisError(outcome.error);
expect(projected.details?.workflow_unknown_publication).toEqual({
evidence_id: evidence.evidence_id,
evidence_retention: "retained",
total_questions: 3,
created_questions: 1,
existing_questions: 1,
next_question_index: 2,
});
expect(projected.remediation.action).toContain("get_evidence_bundle");
const invocation = projectAnalysisError(
outcome.error.retainPartialObservation(evidence),
"invocation",
);
expect(invocation.details?.workflow_unknown_publication).toMatchObject({
evidence_retention: "invocation-only",
});
expect(invocation.details?.partial_observation).toEqual(evidence);
expect(invocation.remediation.action).not.toContain("get_evidence_bundle");
expect(records.listUnknowns()).toHaveLength(1);
expect(records.evidenceById(evidence.evidence_id)?.normalized_result).toEqual(
result,
);
expect(
recordWorkflowUnknowns({
name: "trace_feature",
result,
evidenceId: evidence.evidence_id,
evidenceRetained: true,
recordUnknown: (input) => records.recordUnknown(input),
}).ok,
).toBe(true);
expect(records.listUnknowns()).toHaveLength(2);
});
it.each([
" Which boundary? ",
"\nWhich boundary?\t",
+55 -13
View File
@@ -1,15 +1,17 @@
import { AnalysisWorkScope } from "./AnalysisWorkScope.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { jsonStringByteLength } from "../domain/jsonStringByteLength.js";
import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js";
import { REA_WORKFLOW_PROVIDER } from "../domain/investigationProviders.js";
import { workflowAnalysisProfile } from "./WorkflowAnalysisProfile.js";
import type {
AnalysisOperation,
AnalysisOperationPort,
} from "./AnalysisProvider.js";
import { ok } from "../domain/result.js";
import { err, ok } from "../domain/result.js";
import type { EnhancedResult } from "./EnhancedToolTypes.js";
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { WorkflowSnapshotRecordInput } from "./binary/BinarySessionRecords.js";
import {
analysisProfileSchema,
@@ -101,12 +103,10 @@ export const executeComposedWorkflow = async (
const observed: AnalysisOperationPort = {
resourceScope: analysis.resourceScope,
execute: async (operation, parameters, options) => {
const execution = await analysis.execute(operation, parameters, {
let execution = await analysis.execute(operation, parameters, {
...options,
workScope,
});
if (execution.ok && execution.value.evidenceId !== undefined)
evidenceLinks.add(execution.value.evidenceId);
if (execution.ok && execution.value.sourceEvidence !== undefined) {
const recorded = recordEvidenceSources(
(source) => sourceLedger.record(source),
@@ -114,6 +114,26 @@ export const executeComposedWorkflow = async (
);
if (!recorded.ok) return recorded;
}
if (
execution.ok &&
options?.retainEvidence === true &&
execution.value.evidenceId === undefined
) {
const source = createAnalysisExecutionEvidence(
operation,
parameters,
execution.value,
"observed",
);
const recorded = recordEvidenceSources(
(evidence) => sourceLedger.record(evidence),
[source],
);
if (!recorded.ok) return recorded;
execution = ok({ ...execution.value, evidenceId: source.evidence_id });
}
if (execution.ok && execution.value.evidenceId !== undefined)
evidenceLinks.add(execution.value.evidenceId);
if (execution.ok && execution.value.cachePolicy === "live")
cachePolicy = "live";
if (execution.ok)
@@ -309,6 +329,8 @@ interface WorkflowUnknownInput {
readonly result: JsonValue;
readonly evidenceId: string;
readonly recordUnknown: UnknownRegistryPort["recordUnknown"] | undefined;
/** The caller confirmed the completed Evidence is in its investigation. */
readonly evidenceRetained?: boolean;
}
/** Retain residual workflow questions using the shared investigation policy. */
@@ -317,6 +339,7 @@ export const recordWorkflowUnknowns = ({
result,
evidenceId,
recordUnknown,
evidenceRetained,
}: WorkflowUnknownInput):
| ReturnType<UnknownRegistryPort["recordUnknown"]>
| { readonly ok: true; readonly value: null } => {
@@ -331,7 +354,27 @@ export const recordWorkflowUnknowns = ({
!Array.isArray(result.residual_unknowns)
)
return { ok: true, value: null };
for (const question of result.residual_unknowns) {
const questions = result.residual_unknowns;
const total = questions.reduce<number>(
(count, value) => count + (typeof value === "string" ? 1 : 0),
0,
);
let created = 0;
let existing = 0;
const stopped = (error: AnalysisError, index: number) =>
err(
error.retainWorkflowUnknownPublication({
evidence_id: evidenceId,
evidence_retention:
evidenceRetained === true ? "retained" : "unverified",
total_questions: total,
created_questions: created,
existing_questions: existing,
next_question_index: index,
}),
);
for (let index = 0; index < questions.length; index += 1) {
const question = questions[index];
if (typeof question !== "string") continue;
const input = workflowUnknownInput(evidenceId, {
question,
@@ -351,16 +394,15 @@ export const recordWorkflowUnknowns = ({
],
relationships: [],
});
if (!input.ok) return input;
if (!input.ok) return stopped(input.error, index);
const recorded = recordUnknown(input.value);
if (
!recorded.ok &&
!(
recorded.error instanceof UnknownRegistryError &&
recorded.error.reason === "already-exists"
)
if (recorded.ok) created += 1;
else if (
recorded.error instanceof UnknownRegistryError &&
recorded.error.reason === "already-exists"
)
return recorded;
existing += 1;
else return stopped(recorded.error, index);
}
return { ok: true, value: null };
};
+26 -10
View File
@@ -21,13 +21,21 @@ export const resolveXrefsAddress = async (
return named.value !== null
? ok(named.value)
: err(
new AnalysisInputError("xrefs", undefined, [
new AnalysisInputError(
"xrefs",
{
path: ["address"],
reason: "invalid_value",
message: `Unknown analyzed symbol name ${JSON.stringify(selector)}. Use an exact analyzed name or a hexadecimal address.`,
conformsToInputContract: true,
remediationAction:
"For a function, use rea search --kind procedures to find an analyzed address. For exports, inspect-macho (Mach-O) or inspect-native-exports (PE/ELF) may help; exports can omit local symbols and use different address coordinates. Verify the address in the selected provider, then retry xrefs.",
},
]),
[
{
path: ["address"],
reason: "invalid_value",
message: `Unknown analyzed symbol name ${JSON.stringify(selector)}. Use an exact analyzed name or a hexadecimal address.`,
},
],
),
);
};
@@ -90,12 +98,20 @@ export const resolveXrefsName = async (
const [address] = addresses;
if (addresses.size <= 1) return ok(address ?? null);
return err(
new AnalysisInputError(operation, undefined, [
new AnalysisInputError(
operation,
{
path: [operation === "xrefs" ? "address" : "name"],
reason: "invalid_value",
message: `Analyzed symbol name ${JSON.stringify(selector)} is ambiguous at ${[...addresses].sort().join(", ")}. Select an exact address.`,
conformsToInputContract: true,
remediationAction:
"Select an exact address from the candidates and call xrefs with that address.",
},
]),
[
{
path: [operation === "xrefs" ? "address" : "name"],
reason: "invalid_value",
message: `Analyzed symbol name ${JSON.stringify(selector)} is ambiguous at ${[...addresses].sort().join(", ")}. Select an exact address.`,
},
],
),
);
};
@@ -208,6 +208,13 @@ it.each(["checks", "sections"])(
await view(source(), { kind: "native-load-image", collection }),
).toMatchObject({
items: { collection, rows: null },
coverage: {
status: "unavailable",
examined: 0,
total: null,
next_offset: null,
exhausted: false,
},
unknowns: expect.arrayContaining([
"Null collection counts or rows mean not recorded or not applicable, not observed absence.",
]),
@@ -252,6 +252,13 @@ it("keeps absent profile and inapplicable result caveats distinct from observed
).toMatchObject({
source_context: { result_limitation_count: null },
items: { rows: null },
coverage: {
status: "unavailable",
examined: 0,
total: null,
next_offset: null,
exhausted: false,
},
unknowns: expect.arrayContaining([
expect.stringContaining("not applicable"),
]),
@@ -777,7 +777,9 @@ describe("analysis snapshot cache policy", () => {
);
},
);
});
describe("document-scoped snapshot admission", () => {
it("retains caller observations without a document while refusing to replay them", () => {
const descriptor: CapabilityDescriptor = {
operation: "procedure_callers",
@@ -829,6 +831,35 @@ describe("analysis snapshot cache policy", () => {
document: "fixture",
}),
).toBe(true);
const ghidra = {
...pseudocode,
provider: { ...pseudocode.provider, id: "ghidra" },
};
expect(
isSnapshotCacheable("procedure_pseudo_code", ghidra, parameters),
).toBe(false);
expect(
isSnapshotCacheable(
"procedure_pseudo_code",
ghidra,
parameters,
"one-shot-target",
),
).toBe(true);
for (const refused of [
pseudocode,
{ ...ghidra, cachePolicy: "live" as const },
{ ...ghidra, effects: { ...ghidra.effects, mutatesArtifact: true } },
{ ...ghidra, effects: { ...ghidra.effects, changesPermissions: true } },
])
expect(
isSnapshotCacheable(
"procedure_pseudo_code",
refused,
parameters,
"one-shot-target",
),
).toBe(false);
});
});
@@ -1218,6 +1249,71 @@ it("does not replay pre-envelope call graphs under the revised workflow profile"
).toBe(false);
});
it("keeps older value-trace Evidence as history without replaying its source IDs", () => {
const cache = new AnalysisSnapshotCache();
const target = ANALYSIS_SNAPSHOT_TARGET;
const profile = ANALYSIS_SNAPSHOT_PROFILE;
const operation = "trace_native_values" as const;
const parameters = { procedure: "0x1000" };
const oldProfile = workflowAnalysisProfile(profile);
const oldResult = {
seed: "0x1000",
target_sha256: target.sha256,
nodes: [],
edges: [],
procedures: [
{
address: "0x1000",
depth: 0,
evidence_id: `ev_${"a".repeat(64)}`,
provider: profile.provider.id,
analysis_profile_digest: profile.digest,
},
],
unknowns: [],
total_nodes: 0,
total_edges: 0,
decompilations: 1,
next_offset: null,
page_truncated: false,
traversal: { status: "complete" },
truncated: false,
limitations: [],
};
const execution = {
...createAnalysisExecution(oldResult, REA_WORKFLOW_PROVIDER, {
subject: target,
}),
analysisProfile: oldProfile,
};
cache.recordWorkflow({ target, profile, operation, parameters, execution });
const evidence = createEvidence(target, REA_WORKFLOW_PROVIDER, {
operation,
parameters,
result: oldResult,
confidence: "derived",
analysisProfile: oldProfile,
});
const exported = cache.export(
target,
profile,
createEvidenceBundle([evidence]),
);
if (!exported.ok) throw exported.error;
const snapshot = parseAnalysisSnapshot(exported.value);
expect(snapshot.evidence_bundle.records).toContainEqual(evidence);
expect(
snapshotEvidenceForQuery(snapshot, {
target,
bindingProfile: profile,
operation,
parameters,
provider: REA_WORKFLOW_PROVIDER,
evidenceProfile: workflowAnalysisProfile(profile, operation),
}),
).toBeUndefined();
});
it("declines source-bearing snapshot entries while replaying omitted and empty source controls", () => {
const cache = new AnalysisSnapshotCache();
const target = ANALYSIS_SNAPSHOT_TARGET;
@@ -1358,3 +1454,15 @@ it("revises the trace_feature workflow profile so earlier results are not replay
"other workflows keep their profile",
).toEqual(workflowAnalysisProfile(profile));
});
it("binds native value-trace absence semantics without changing other workflows", () => {
const profile = ANALYSIS_SNAPSHOT_PROFILE;
const valueTrace = workflowAnalysisProfile(profile, "trace_native_values");
expect(valueTrace.parameters.value_flow_absence).toBe(
"recorded-versus-unavailable-v1",
);
expect(valueTrace.digest).not.toBe(workflowAnalysisProfile(profile).digest);
expect(workflowAnalysisProfile(profile, "trace_call_path")).toEqual(
workflowAnalysisProfile(profile),
);
});
@@ -36,6 +36,7 @@ import type {
AnalysisExecution,
AnalysisOperation,
CapabilityDescriptor,
ExecutionOptions,
} from "../AnalysisProvider.js";
import { OFFICIAL_TOOL_CONTRACTS } from "../../contracts/officialToolContracts.js";
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
@@ -94,6 +95,16 @@ const replayDocumentSelected = (
!DOCUMENT_SCOPED_OPERATIONS.has(operation) ||
typeof parameters.document === "string";
/** Ghidra's private Program belongs to this one-shot target; GUI defaults do not. */
const isOneShotPseudocode = (
operation: AnalysisOperation,
descriptor: CapabilityDescriptor | undefined,
context: ExecutionOptions["snapshotContext"],
): boolean =>
context === "one-shot-target" &&
operation === "procedure_pseudo_code" &&
descriptor?.provider.id === "ghidra";
/**
* Caller and callee walks omit `document`. Their observations are still
* Evidence. Other document-scoped reads, including `procedure_pseudo_code`
@@ -112,14 +123,18 @@ export const isSourceEvidenceRetainable = (
operation: AnalysisOperation,
descriptor: CapabilityDescriptor | undefined,
parameters: Readonly<Record<string, JsonValue>>,
options: { readonly requiresSourceEvidence?: boolean } = {},
options: {
readonly snapshotContext?: ExecutionOptions["snapshotContext"];
readonly requiresSourceEvidence?: boolean;
} = {},
): descriptor is CapabilityDescriptor =>
!STATEFUL_OPERATIONS.has(operation) &&
!RESOURCE_SCOPED_OPERATIONS.has(operation) &&
(!DOCUMENT_SCOPED_OPERATIONS.has(operation) ||
typeof parameters.document === "string" ||
DOCUMENTLESS_SOURCE_EVIDENCE_OPERATIONS.has(operation) ||
options.requiresSourceEvidence === true) &&
options.requiresSourceEvidence === true ||
isOneShotPseudocode(operation, descriptor, options.snapshotContext)) &&
(!CURSOR_DEFAULT_OPERATIONS.has(operation) ||
typeof parameters.address === "string") &&
descriptor?.effects.mutatesArtifact === false &&
@@ -133,9 +148,13 @@ export const isSnapshotCacheable = (
operation: AnalysisOperation,
descriptor: CapabilityDescriptor | undefined,
parameters: Readonly<Record<string, JsonValue>>,
context?: ExecutionOptions["snapshotContext"],
): descriptor is CapabilityDescriptor =>
isSourceEvidenceRetainable(operation, descriptor, parameters) &&
replayDocumentSelected(operation, parameters) &&
isSourceEvidenceRetainable(operation, descriptor, parameters, {
snapshotContext: context,
}) &&
(replayDocumentSelected(operation, parameters) ||
isOneShotPseudocode(operation, descriptor, context)) &&
descriptor.cachePolicy !== "live";
/** Windows Ghidra must admit the selected source through its native snapshot owner. */
@@ -544,6 +563,7 @@ export class AnalysisSnapshotCache {
readonly operation: AnalysisOperation;
readonly parameters: Readonly<Record<string, JsonValue>>;
readonly execution: AnalysisExecution;
readonly evidence?: Evidence;
}): void {
const { target, profile, operation, parameters, execution } = input;
this.select(target, profile);
@@ -591,6 +611,9 @@ export class AnalysisSnapshotCache {
else
this.#replayBindings.set(entry.query_id, {
evidenceId: execution.evidenceId,
...(input.evidence === undefined
? {}
: { sourceEvidence: Object.freeze([input.evidence]) }),
});
}
@@ -11,8 +11,6 @@ export const snapshotReplayBinding = (
evidence: Evidence,
records: ReadonlyMap<string, Evidence>,
): SnapshotReplayBinding | null => {
if (evidence.evidence_links.length === 0)
return { evidenceId: evidence.evidence_id };
const sources: Evidence[] = [];
for (const id of evidence.evidence_links) {
const source = records.get(id);
@@ -20,6 +18,9 @@ export const snapshotReplayBinding = (
if (source === undefined) return null;
sources.push(source);
}
// The replay is a new observation with its own replay limitation. Retain
// its original record as well as the original's immediate sources.
sources.push(evidence);
return {
evidenceId: evidence.evidence_id,
sourceEvidence: Object.freeze(sources),
+46 -13
View File
@@ -35,6 +35,8 @@ import type {
} from "../AnalysisProvider.js";
import type {
BinarySessionPort,
BinaryOpenResult,
OpenSelectionReceipt,
SavedAnalysisSnapshot,
} from "./BinarySessionPort.js";
import {
@@ -478,7 +480,7 @@ export class BinarySession
open(
path: string,
options: BinarySessionOpenOptions = {},
): Promise<Result<BinaryTarget, AnalysisError>> {
): Promise<BinaryOpenResult> {
return this.#open(
(admitted) =>
resolveSessionOpen({
@@ -498,7 +500,7 @@ export class BinarySession
openResolvedTarget(
resolved: Pick<ResolvedSessionOpen, "target" | "route">,
options: Pick<BinarySessionOpenOptions, "signal" | "snapshot"> = {},
): Promise<Result<BinaryTarget, AnalysisError>> {
): Promise<BinaryOpenResult> {
return this.#open(
() =>
Promise.resolve(
@@ -519,20 +521,44 @@ export class BinarySession
admitted: BinarySessionOpenOptions,
) => Promise<Result<ResolvedSessionOpen, AnalysisError>>,
options: BinarySessionOpenOptions,
): Promise<Result<BinaryTarget, AnalysisError>> {
): Promise<BinaryOpenResult> {
return this.#serialize(async (admittedThrough) => {
const refused = this.#targetAcquisitionRefused("open_binary");
if (refused !== undefined) return refused;
const admitted = await this.#withLoadedSnapshot(options);
if (!admitted.ok) return admitted;
return this.#openWithinTransition(
admittedThrough,
() => resolve(admitted.value),
admitted.value,
);
const attempt = async (): Promise<
Result<BinaryTarget, AnalysisError>
> => {
const refused = this.#targetAcquisitionRefused("open_binary");
if (refused !== undefined) return refused;
const admitted = await this.#withLoadedSnapshot(options);
if (!admitted.ok) return admitted;
return this.#openWithinTransition(
admittedThrough,
() => resolve(admitted.value),
admitted.value,
);
};
const result = await attempt();
return result.ok
? result
: { ...result, selection: this.#openSelectionReceipt() };
});
}
#openSelectionReceipt(): OpenSelectionReceipt {
if (this.#active !== undefined)
return {
kind: "binary",
path: this.#active.target.path,
sha256: this.#active.target.sha256,
};
if (this.#activeBundleResources !== undefined)
return {
kind: "resources",
path: this.#activeBundleResources.root.canonicalPath,
caller_path: this.#activeBundleResources.root.callerPath,
};
return { kind: "none" };
}
/** Read the staged snapshot only after earlier lifecycle transitions have settled. */
async #withLoadedSnapshot(
options: BinarySessionOpenOptions,
@@ -1418,6 +1444,7 @@ export class BinarySession
active: this.#active,
operation: name,
parameters: arguments_,
snapshotContext: options?.snapshotContext,
nativeSourceAdmitted: this.#active?.nativeSourceAdmitted === true,
unboundOperationError: (operation, route) =>
this.#providerRouter.unboundOperationError(operation, route),
@@ -1480,6 +1507,7 @@ export class BinarySession
if (
execution.ok &&
isSourceEvidenceRetainable(name, capability, arguments_, {
snapshotContext: options?.snapshotContext,
requiresSourceEvidence: execution.value.requiresSourceEvidence === true,
}) &&
profile !== undefined
@@ -1498,7 +1526,11 @@ export class BinarySession
"observed",
);
const recorded = this.recordEvidence(evidence);
if (!recorded.ok) return recorded;
if (!recorded.ok) {
// The provider finished. A full ledger must not discard that observation.
recorded.error.retainPartialObservation(evidence);
return recorded;
}
const retained = { ...execution.value, evidenceId: evidence.evidence_id };
if (cacheable && execution.value.cachePolicy !== "live")
this.recordSnapshot({
@@ -1507,6 +1539,7 @@ export class BinarySession
operation: name,
parameters: arguments_,
execution: retained,
evidence: this.evidenceForAnalysis(evidence.evidence_id) ?? evidence,
});
return ok(retained);
}
@@ -157,6 +157,13 @@ it("opens a resource-only bundle without executable identity and keeps snapshot
session_kind: "bundle_resources",
resource_root: { canonical_path: opened.value.canonicalPath },
});
const rejectedBinary = await session.open(join(rootPath, "missing-binary"));
if (rejectedBinary.ok) throw new Error("Expected a missing binary refusal");
expect(rejectedBinary.selection).toEqual({
kind: "resources",
path: opened.value.canonicalPath,
caller_path: opened.value.callerPath,
});
const snapshot = session.exportAnalysisSnapshot();
if (snapshot.ok)
throw new Error("resource session exported a binary snapshot");
@@ -652,10 +659,15 @@ it("keeps an earlier resource close failure primary when binary candidate cleanu
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed binary open");
expect(result.selection).toEqual({ kind: "none" });
expect(result.error).toBe(originalFailure);
expect(result.error.cleanupPrimary).toBeUndefined();
expect(binaryCloseAttempts).toBe(1);
expect(resourceCloseAttempts).toBe(1);
expect(await session.status()).toMatchObject({
open: false,
cleanup_pending: true,
});
expect(await session.close()).toMatchObject({ ok: true });
expect(resourceCloseAttempts).toBe(2);
});
@@ -15,6 +15,7 @@ import type {
AnalysisExecution,
AnalysisOperation,
CapabilityDescriptor,
ExecutionOptions,
} from "../AnalysisProvider.js";
import {
isSnapshotCacheable,
@@ -41,6 +42,7 @@ interface PrepareSessionExecutionInput {
readonly active: ActiveExecutionBinding | undefined;
readonly operation: AnalysisOperation;
readonly parameters: Readonly<Record<string, JsonValue>>;
readonly snapshotContext?: ExecutionOptions["snapshotContext"];
readonly nativeSourceAdmitted: boolean;
readonly unboundOperationError: (
operation: AnalysisOperation,
@@ -76,6 +78,14 @@ export const prepareSessionExecution = (
active.route.binding?.identity.id ?? active.route.identity.id,
operation,
capability?.reason ?? "operation is not declared by this provider",
{
...(capability?.userMessage === undefined
? {}
: { userMessage: capability.userMessage }),
...(capability?.remediation === undefined
? {}
: { remediationAction: capability.remediation }),
},
),
);
}
@@ -86,7 +96,12 @@ export const prepareSessionExecution = (
: undefined;
const cacheable =
profile !== undefined &&
isSnapshotCacheable(operation, capability, parameters);
isSnapshotCacheable(
operation,
capability,
parameters,
input.snapshotContext,
);
const cached =
cacheable &&
(!requiresNativeSourceAdmissionBeforeReplay(capability.provider.id) ||
@@ -115,9 +115,14 @@ it("keeps unbound observations out of persisted target snapshots", async () => {
const parameters = { document: "0", address: "0x1000" };
expect((await session.open(process.execPath)).ok).toBe(true);
const bound = await session.execute("address_name", parameters);
expect(bound.ok).toBe(true);
if (!bound.ok) throw bound.error;
const child = deep.created[0];
if (child === undefined || !bound.ok) throw new Error("Expected bound read");
if (child === undefined) throw new Error("Expected bound read");
const liveReplay = await session.execute("address_name", parameters);
if (!liveReplay.ok) throw liveReplay.error;
expect(
liveReplay.value.sourceEvidence?.map((source) => source.evidence_id),
).toEqual([bound.value.evidenceId]);
child.execute = () =>
Promise.resolve(
ok(
@@ -169,13 +174,14 @@ it("keeps unbound observations out of persisted target snapshots", async () => {
).ok,
).toBe(true);
const replay = await session.execute("address_name", parameters);
expect(replay.ok).toBe(true);
if (replay.ok) {
expect(replay.value.evidenceId).toBe(bound.value.evidenceId);
expect(replay.value.limitations).toContain(
"Loaded from a local REA analysis snapshot; this call did not re-run the provider.",
);
}
if (!replay.ok) throw replay.error;
expect(replay.value.evidenceId).toBe(bound.value.evidenceId);
expect(
replay.value.sourceEvidence?.map((source) => source.evidence_id),
).toEqual([bound.value.evidenceId]);
expect(replay.value.limitations).toContain(
"Loaded from a local REA analysis snapshot; this call did not re-run the provider.",
);
});
describe("BinarySession open liveness", () => {
+1 -1
View File
@@ -12,8 +12,8 @@ import {
snapshotMatchesTarget,
snapshotTarget,
type SnapshotBindingMismatch,
type AnalysisSnapshot,
} from "../../domain/analysisSnapshot.js";
import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js";
import { parseBinaryTarget } from "../BinaryTargetResolver.js";
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import {
+19 -1
View File
@@ -30,6 +30,24 @@ import type {
import type { SavedAnalysisSnapshot } from "../../domain/savedAnalysisSnapshot.js";
export type { SavedAnalysisSnapshot } from "../../domain/savedAnalysisSnapshot.js";
/** Selection observed at the end of one serialized open attempt. */
export type OpenSelectionReceipt =
| { readonly kind: "binary"; readonly path: string; readonly sha256: string }
| {
readonly kind: "resources";
readonly path: string;
readonly caller_path: string;
}
| { readonly kind: "none" };
export type BinaryOpenResult =
| { readonly ok: true; readonly value: BinaryTarget }
| {
readonly ok: false;
readonly error: AnalysisError;
readonly selection: OpenSelectionReceipt;
};
/** Target lifecycle used by CLI and MCP without exposing a concrete provider. */
export interface BinarySessionPort
extends AnalysisOperationPort, InvestigationRecordPort {
@@ -72,7 +90,7 @@ export interface BinarySessionPort
readonly loadSnapshot?: AnalysisSnapshotLoader;
readonly providerId?: AnalysisProviderSelector;
},
): Promise<Result<BinaryTarget, AnalysisError>>;
): Promise<BinaryOpenResult>;
/** Open a resource-only app root without selecting or inventing an executable target. */
openBundleResources(
path: string,
@@ -98,6 +98,7 @@ it.each([
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.selection).toEqual({ kind: "none" });
expect(session.activeTarget()).toBeUndefined();
expect(await session.status()).toMatchObject({
open: false,
@@ -119,6 +120,7 @@ it.each([
expect(projectAnalysisError(result.error).code).toBe(
cancelled ? "cancelled" : "unreadable_output",
);
expect(result.selection).toEqual({ kind: "none" });
expect(projectAnalysisError(result.error)).toMatchObject({
details: {
diagnostics: {
@@ -34,7 +34,7 @@ export const runtimeFailureReason = (
failure: AnalysisError,
): string | undefined => {
if (failure._tag === "AnalysisCapabilityUnavailableError")
return failure.message;
return failure.userMessage ?? failure.message;
return PROVIDER_WIDE_FAILURES.has(failure._tag)
? `${failure._tag}: ${failure.message}`
: undefined;
@@ -120,6 +120,7 @@ const TARGETS: TargetFixtures = {
ines: { ...archive, format: "ines" },
"godot-pck": { ...archive, format: "godot-pck" },
unityfs: { ...archive, format: "unityfs" },
wxapkg: { ...archive, format: "wxapkg" },
uimage: { ...archive, format: "uimage" },
uf2: { ...archive, format: "uf2" },
dfuse: { ...archive, format: "dfuse" },
@@ -535,6 +535,12 @@ const publishOne = async (input: {
const caveats = [
...new Set([...infoCaveats, ...decompiled.value.limitations]),
];
const observationSource: NonNullable<
DecompileDirectoryFunctionRecord["provider_source"]
> =
decompiled.value.sourceDescriptor === undefined
? { status: "unknown", reason: "source_not_available" }
: { status: "reported", source: decompiled.value.sourceDescriptor };
const pseudocode = decompiled.value.result;
if (typeof pseudocode !== "string" || pseudocode.length === 0) {
const reason = pseudocodeFailure(pseudocode);
@@ -543,6 +549,7 @@ const publishOne = async (input: {
result: await input.session.append(
failureRecord(input.procedure, body.fact, reason, {
limitations: caveats,
providerSource: observationSource,
}),
),
bodySupport: body.support,
@@ -580,6 +587,7 @@ const publishOne = async (input: {
failureRecord(input.procedure, body.fact, reason, {
relativePath,
limitations: caveats,
providerSource: observationSource,
}),
),
bodySupport: body.support,
@@ -591,6 +599,11 @@ const publishOne = async (input: {
status: "written",
relative_path: relativePath,
sha256: committed.sha256,
// Matching existing bytes prove equality, not who originally wrote them.
provider_source:
committed.status === "matched"
? { status: "unknown", reason: "existing_file_origin_unknown" }
: observationSource,
body: bodyRecord(body.fact),
// The header may truncate; the journal keeps the complete list.
...(caveats.length === 0 ? {} : { limitations: caveats }),
@@ -889,6 +902,7 @@ const failureRecord = (
readonly relativePath?: string;
/** Caveats of the observations this attempt used. */
readonly limitations?: readonly string[];
readonly providerSource?: DecompileDirectoryFunctionRecord["provider_source"];
} = {},
): DecompileDirectoryFunctionRecord => ({
address: procedure.address,
@@ -896,6 +910,9 @@ const failureRecord = (
status: "failed",
body: bodyRecord(body),
failure: reason,
...(options.providerSource === undefined
? {}
: { provider_source: options.providerSource }),
...(options.relativePath === undefined
? {}
: { relative_path: options.relativePath }),
@@ -802,11 +802,19 @@ const journalIdentityMismatch = (
);
};
const incompatibleJournal = (): AnalysisInputError =>
decompileInputError(
"output_directory",
"The decompile journal is not a compatible export.",
const incompatibleJournal = (): AnalysisInputError => {
const message = "The decompile journal is not a compatible export.";
return new AnalysisInputError(
"decompile_all",
{
userMessage: message,
conformsToInputContract: true,
remediationAction:
"Inspect the journal and use the REA version that created it, or choose a new output directory. The journal may be damaged or from an unsupported version; existing files were left unchanged.",
},
[{ path: ["output_directory"], reason: "invalid_value", message }],
);
};
const parseJson = (line: string | undefined): unknown => {
if (line === undefined) return undefined;
@@ -5,7 +5,10 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
/** Source facts remain available even when a completed observation is cancelled. */
export type AnalysisResultWithSources<Value> = Result<Value, AnalysisError> & {
export type AnalysisResultWithSources<Value, Failure = AnalysisError> = Result<
Value,
Failure
> & {
readonly sourceEvidence?: readonly Evidence[];
};
@@ -10,7 +10,10 @@ import {
} from "../../contracts/native/functionMatchingContracts.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import { readFunctionInventory } from "../../domain/native/functionInventory.js";
import { nativeFunctionMatchingSchema } from "../../domain/native/functionMatching.js";
import {
nativeFunctionMatchingSchema,
NATIVE_FUNCTION_MATCHING_PREDICATE,
} from "../../domain/native/functionMatching.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
@@ -20,7 +23,6 @@ import { resolveNativeMatchingSources } from "./NativeMatchingSources.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { NativeMatchingAllowance } from "../../config/nativeMatchingAllowance.js";
import { FUNCTION_COMPARISON_PROVIDER } from "../../domain/comparisonEvidenceIdentity.js";
import { NATIVE_FUNCTION_MATCHING_PREDICATE } from "../../domain/native/functionMatching.js";
const OPERATION = "match_native_functions";
@@ -1,6 +1,10 @@
import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js";
import { describe, expect, it } from "vitest";
import { traceNativeValues } from "./NativeValueTrace.js";
import {
traceNativeValues as traceNativeValuesCore,
UNRECORDED_NATIVE_VALUE_FLOW,
} from "./NativeValueTrace.js";
import { executeComposedWorkflow } from "../WorkflowEvidence.js";
import {
createAnalysisExecution,
type AnalysisOperationPort,
@@ -26,6 +30,27 @@ import {
valueTraceProvider as provider,
valueTraceSubject as subject,
} from "./NativeValueTrace.fixture.js";
const traceNativeValues: typeof traceNativeValuesCore = async (
source,
parameters,
signal,
) => {
const result = await executeComposedWorkflow(source, (observed) =>
traceNativeValuesCore(observed, parameters, signal),
);
return result.ok ? ok(result.value.result) : result;
};
it("does not return a source ID without an Evidence owner", async () => {
const result = await traceNativeValuesCore(valueTraceDiamond().port, {
procedure: "0x1000",
});
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError");
});
const analysis: AnalysisOperationPort = {
execute: async (operation, parameters) => {
if (operation === "resolve_native_call_targets")
@@ -168,12 +193,47 @@ describe("native value trace seed admission", () => {
expect.objectContaining({ address: base.procedure.address }),
],
unknowns: [
expect.objectContaining({
reason: "Provider has no native def-use model",
}),
{
procedure: base.procedure.address,
address: null,
reason: UNRECORDED_NATIVE_VALUE_FLOW,
},
],
});
});
it("preserves an explicit unavailable reason for that procedure", async () => {
const base = functionDossierSchema.parse(ghidraFunctionDossier());
const providerReason = "selected procedure has no p-code model";
const result = await traceNativeValues(
{
execute: async () =>
ok(
createAnalysisExecution(
{
...base,
native_value_flow: {
available: false,
reason: providerReason,
limitations: [],
},
},
provider,
{ subject },
),
),
},
{ procedure: base.procedure.address },
);
if (!result.ok) throw result.error;
expect(nativeValueTraceSchema.parse(result.value).unknowns).toEqual([
{
procedure: base.procedure.address,
address: null,
reason: providerReason,
},
]);
});
});
describe("native value parameter-use node membership", () => {
+25 -10
View File
@@ -7,6 +7,7 @@ import {
import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { err, ok } from "../../domain/result.js";
@@ -27,6 +28,10 @@ import { createValueTraceBudget } from "./NativeValueTraceBudget.js";
export const CLEANUP_STOPPED_VALUE_TRACE =
"Trace stopped because the provider did not confirm cleanup of resources it owns";
/** Fallback when a dossier has no native value-flow facet for this procedure. */
export const UNRECORDED_NATIVE_VALUE_FLOW =
"Native def-use was not recorded for this procedure.";
/** Compose bounded high-p-code def-use graphs across exactly referenced call destinations. */
export const traceNativeValues = async (
analysis: AnalysisOperationPort,
@@ -116,6 +121,7 @@ export const traceNativeValues = async (
{
...(signal === undefined ? {} : { signal }),
...(next.depth > 0 ? { selectorOrigin: "provider" as const } : {}),
retainEvidence: true,
},
);
decompilations++;
@@ -129,12 +135,13 @@ export const traceNativeValues = async (
}
continue;
}
const evidence = createAnalysisExecutionEvidence(
"analyze_function",
{ procedure: next.procedure },
execution.value,
"observed",
);
const partialEvidence = () =>
createAnalysisExecutionEvidence(
"analyze_function",
{ procedure: next.procedure },
execution.value,
"observed",
);
const parsed = functionDossierSchema.safeParse(execution.value.result);
if (!parsed.success || execution.value.subject == null)
return err(
@@ -142,7 +149,7 @@ export const traceNativeValues = async (
"trace_native_values",
"Function dossier lacks validated facts or artifact identity",
parsed.success ? undefined : { cause: parsed.error },
).retainPartialObservation(evidence),
).retainPartialObservation(partialEvidence()),
);
const dossier = parsed.data;
const address = dossier.procedure.address;
@@ -155,9 +162,17 @@ export const traceNativeValues = async (
new AnalysisOutputError(
"trace_native_values",
"Cross-function target digest mismatch",
).retainPartialObservation(evidence),
).retainPartialObservation(partialEvidence()),
);
const evidenceId = execution.value.evidenceId;
if (evidenceId === undefined)
return err(
new AnalysisCapabilityUnavailableError(
"rea",
"trace_native_values",
"The function observation has no Evidence retention owner.",
).retainPartialObservation(partialEvidence()),
);
const evidenceId = evidence.evidence_id;
procedures.push({
address,
depth: next.depth,
@@ -170,7 +185,7 @@ export const traceNativeValues = async (
unknowns.push({
procedure: address,
address: null,
reason: flow?.reason ?? "Provider has no native def-use model",
reason: flow?.reason ?? UNRECORDED_NATIVE_VALUE_FLOW,
});
continue;
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { AnalysisOperationPort } from "../AnalysisProvider.js";
import { ok } from "../../domain/result.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
@@ -34,6 +35,21 @@ describe("native value trace child stops", () => {
0,
VALUE_TRACE_DIAMOND_READS.indexOf(read) + 1,
);
// The trace cites each completed dossier. These cases cover stop behavior,
// so the fixture only has to satisfy that precondition.
const withSourceEvidence = (
port: AnalysisOperationPort,
): AnalysisOperationPort => ({
execute: async (operation, parameters, options) => {
const execution = await port.execute(operation, parameters, options);
if (!execution.ok || execution.value.evidenceId !== undefined)
return execution;
return ok({
...execution.value,
evidenceId: `ev_${"ab".repeat(32)}`,
});
},
});
it.each(childReads)(
"returns a cancelled %s read without a partial trace",
@@ -43,7 +59,7 @@ describe("native value trace child stops", () => {
[read]: cancelled,
});
const result = await traceNativeValues(port, {
const result = await traceNativeValues(withSourceEvidence(port), {
procedure: "0x1000",
max_depth: 1,
});
@@ -86,7 +102,7 @@ describe("native value trace child stops", () => {
};
const result = await traceNativeValues(
aborting,
withSourceEvidence(aborting),
{ procedure: "0x1000", max_depth: 1 },
controller.signal,
);
@@ -114,7 +130,7 @@ describe("native value trace child stops", () => {
"resolve_native_call_targets:0x1008": failure,
});
const result = await traceNativeValues(port, {
const result = await traceNativeValues(withSourceEvidence(port), {
procedure: "0x1000",
max_depth: 1,
});
@@ -549,7 +549,7 @@ export class ReverseEngineeringService {
? "REA_OBJDUMP_COMMAND"
: "REA_RIZIN_COMMAND";
earlyFailure = new AnalysisCapabilityUnavailableError(
provider,
profile.identity.id,
operation,
`Unable to run configured command (${String(code ?? "unknown error")})`,
{
@@ -708,10 +708,10 @@ export class ReverseEngineeringService {
const limitations = [
...(absolutePath === undefined
? [
"The objdump version is not reported; the output lists BFD targets advertised by the installed tool and does not establish support for the selected artifact.",
"The objdump implementation and version are unknown. The captured output is the selected command's response and does not establish support for an artifact.",
]
: [
"The executable version and supported formats are reported as unknown because no reliable version probe was performed.",
"The executable implementation, version and supported formats are unknown because no reliable identity probe was performed.",
]),
"A non-zero process exit is preserved as an observed command result.",
...(operation === "inspect_with_objdump" &&
@@ -759,9 +759,7 @@ export class ReverseEngineeringService {
`Command completion is unknown because the operation failed with ${failure._tag}: ${failure.message}`,
]),
...(absolutePath === undefined
? [
"This output lists BFD targets advertised by the installed objdump and is independent of the selected artifact.",
]
? []
: [
"When both are available, pre-run and post-run digests detect artifact changes across the provider call but cannot prove the exact bytes consumed during the call.",
]),
@@ -168,6 +168,40 @@ it("requires the producer's file subject format even for authentic Evidence", as
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
});
it.each(["left", "right"] as const)(
"names the %s argument whose Evidence is rejected",
async (side) => {
const tampered = JSON.parse(JSON.stringify(evidence(facts("f"))));
tampered.normalized_result.interface.entries[0].name = "tampered";
const report = wasmArtifactFixture();
const otherSelection = createEvidence(
{
path: report.artifact.path,
sha256: report.artifact.sha256,
format: "file",
},
{ id: "fixture", name: "typed fixture", version: "1" },
{
operation: "inspect_wasm_artifact",
parameters: { path: "/artifacts/other.wasm" },
result: report,
confidence: "observed",
},
);
for (const rejected of [tampered, otherSelection])
expect(
await compareWasmInterfaces({
left: side === "left" ? rejected : evidence(facts("f")),
right: side === "right" ? rejected : evidence(facts("f")),
scope: "both",
}),
).toMatchObject({
ok: false,
error: { issues: [{ path: [side], reason: "invalid_value" }] },
});
},
);
it("resolves exact retained references and permits cancellation between comparison batches", async () => {
const left = evidence(facts("f"));
const right = evidence(facts("f", "i64"));
@@ -40,16 +40,19 @@ import {
const OPERATION = "compare_wasm_interfaces";
const parseInspection = (
raw: unknown,
side: "left" | "right",
): Result<{ evidence: Evidence; result: WasmArtifact }, AnalysisInputError> => {
const authenticated = safeParseEvidence(raw);
if (!authenticated.ok) return inspectionInputFailure(authenticated.error);
if (!authenticated.ok)
return inspectionInputFailure(authenticated.error, side);
const evidence = authenticated.value;
if (evidence.operation !== "inspect_wasm_artifact")
return inspectionInputFailure(
new TypeError("Expected complete inspect_wasm_artifact Evidence"),
side,
);
const parsed = wasmArtifactSchema.safeParse(evidence.normalized_result);
if (!parsed.success) return inspectionInputFailure(parsed.error);
if (!parsed.success) return inspectionInputFailure(parsed.error, side);
const result = parsed.data;
if (
!wasmArtifactMatchesSubject(
@@ -67,16 +70,19 @@ const parseInspection = (
new TypeError(
"WASM Evidence subject/selection does not match normalized artifact identity",
),
side,
);
return ok({ evidence, result });
};
/** Attribute a rejected side to the input argument that supplied it. */
const inspectionInputFailure = (
cause: Error,
side: "left" | "right",
): Result<never, AnalysisInputError> =>
err(
new AnalysisInputError(OPERATION, { cause }, [
{ path: [], reason: "invalid_value", message: cause.message },
{ path: [side], reason: "invalid_value", message: cause.message },
]),
);
@@ -107,9 +113,9 @@ export const compareWasmInterfaces = async (
try {
await setImmediate();
assertActive();
const parsedLeft = parseInspection(resolved.value.input.left);
const parsedLeft = parseInspection(resolved.value.input.left, "left");
if (!parsedLeft.ok) return parsedLeft;
const parsedRight = parseInspection(resolved.value.input.right);
const parsedRight = parseInspection(resolved.value.input.right, "right");
if (!parsedRight.ok) return parsedRight;
const left = parsedLeft.value;
const right = parsedRight.value;
+5 -1
View File
@@ -270,8 +270,12 @@ class ArtifactClient implements AnalysisClient {
);
}
if (operation === "inspect_keyed_archive") {
// A bundle target reads bundle-relative archives whatever its
// executable admitted as; only a plist opened on its own is standalone.
const standalone =
this.target.kind === "artifact" && this.target.format === "plist";
this.target.bundleInfoPlist === undefined &&
this.target.kind === "artifact" &&
this.target.format === "plist";
const selectedPath = artifactTargetSelectedPath(this.target);
const result = standalone
? await inspectStandaloneKeyedArchive({
+6
View File
@@ -26,6 +26,8 @@ import type {
export interface ArtifactReaderFailureOptions extends ErrorOptions {
/** Recovery guidance established by the producing adapter, separate from diagnostics. */
readonly userMessage?: string | undefined;
/** Regular-file path observed behind a rejected selected symlink; retry still revalidates it. */
readonly resolvedPath?: string | undefined;
readonly cancellation?: { readonly reason: unknown } | undefined;
readonly cleanup?: AnalysisCleanupObservation | undefined;
/** Exact authority for all reported cleanup; undefined leaves ownership unknown. */
@@ -197,6 +199,7 @@ export interface ArtifactReader {
| "ines"
| "godot-pck"
| "unityfs"
| "wxapkg"
| "uimage"
| "pyinstaller"
| "dotnet-bundle"
@@ -230,6 +233,7 @@ export interface ArtifactReader {
/** Typed adapter failure translated at provider boundary. */
export class ArtifactReaderFailure extends Error {
readonly userMessage: string | undefined;
readonly resolvedPath: string | undefined;
readonly cancellation: { readonly reason: unknown } | undefined;
readonly cleanup: AnalysisCleanupObservation | undefined;
readonly cleanupLeases: readonly AnalysisCleanupLease[] | undefined;
@@ -255,6 +259,7 @@ export class ArtifactReaderFailure extends Error {
super(message, options);
this.name = "ArtifactReaderFailure";
this.userMessage = options?.userMessage;
this.resolvedPath = options?.resolvedPath;
this.cancellation =
reason === "cancelled"
? (options?.cancellation ?? { reason: options?.cause })
@@ -306,6 +311,7 @@ export class ArtifactReaderFailure extends Error {
{
cause: primary,
userMessage: primary.userMessage,
resolvedPath: primary.resolvedPath,
cancellation: primary.cancellation,
cleanup: combineCleanup(primary.cleanup, cleanup),
cleanupLeases:
@@ -8,6 +8,9 @@ import { assertArtifactFileSelectedSource } from "./ArtifactFileSource.js";
import { assertArtifactSelectedSource } from "./ArtifactSelectedSource.js";
import { readStableArtifact } from "./readStableArtifact.js";
import { DirectoryArtifactReader } from "./DirectoryArtifactReader.js";
import { createDesktopApplicationService } from "../composition/desktop.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { desktopApplicationSchema } from "../domain/desktopApplication.js";
it.skipIf(process.platform === "win32")(
"admits a caller-selected directory symlink without changing its target identity",
@@ -46,6 +49,54 @@ it.skipIf(process.platform === "win32")(
},
);
it.skipIf(process.platform === "win32")(
"rejects desktop inspection when the selected spelling traverses a regular file",
async () => {
const root = await createTestTempDirectory(
"rea-desktop-enotdir-intermediate-",
);
const app = join(root, "Demo.app");
await mkdir(app);
await writeFile(
join(app, "Info.plist"),
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict><key>CFBundleIdentifier</key><string>com.example.demo</string></dict></plist>`,
);
await writeFile(join(app, "marker.txt"), "marker\n");
await writeFile(join(root, "regular"), "regular file\n");
const service = createDesktopApplicationService();
try {
const invalid = await service.inspect({
path: `${root}/regular/../Demo.app`,
max_entries: 32,
max_probe_bytes: 4096,
});
expect(invalid.ok).toBe(false);
if (invalid.ok) throw new Error("expected ENOTDIR refusal");
expect(projectAnalysisError(invalid.error)).toMatchObject({
code: "target_unavailable",
category: "unavailable",
});
const valid = await service.inspect({
path: app,
max_entries: 32,
max_probe_bytes: 4096,
});
expect(valid.ok).toBe(true);
if (!valid.ok) throw valid.error;
expect(
desktopApplicationSchema
.parse(valid.value.normalized_result)
.files.map((file) => file.path)
.sort(),
).toEqual(["Info.plist", "marker.txt"]);
} finally {
await service.close();
}
},
);
it.skipIf(process.platform === "win32")(
"reports a replaced ancestor symlink loop as a changed admitted source",
async () => {
@@ -105,3 +156,35 @@ it.each(["ENOENT", "ENOTDIR", "ELOOP", "ENAMETOOLONG", "EACCES", "EIO"])(
});
},
);
it.skipIf(process.platform === "win32")(
"keeps a rejected symlink without a hint when its resolved target lookup fails",
async () => {
const root = await createTestTempDirectory("rea-stable-symlink-lookup-");
const target = join(root, "target.bin");
const selected = join(root, "selected");
await writeFile(target, "carrier");
await symlink(target, selected);
const selectedStats = await lstat(selected, { bigint: true });
const failure = Object.assign(new Error("target lookup failed"), {
code: "EACCES",
});
await expect(
readStableArtifact(selected, 1024, undefined, {
lstat: async (path) => {
if (path === selected) return selectedStats;
throw failure;
},
realpath: async () => target,
open: async () => {
throw new Error("A rejected symlink must not be opened");
},
}),
).rejects.toMatchObject({
reason: "path",
resolvedPath: undefined,
message: `Expected a regular file without a symlink: ${selected}`,
});
},
);
+27 -1
View File
@@ -78,7 +78,21 @@ export class AsarArtifactReader implements ArtifactReader {
jsonBytes: admitted.jsonBytes,
};
assertAsarReadActive(signal);
return readAsarListing(admitted.header, signal);
try {
return await readAsarListing(admitted.header, signal);
} catch (cause: unknown) {
// Listing names members relative to this archive; keep which archive.
if (
cause instanceof ArtifactReaderFailure &&
(cause.reason === "format" || cause.reason === "path")
)
throw new ArtifactReaderFailure(
cause.reason,
`${cause.message} in ${this.path}`,
{ cause },
);
throw cause;
}
}, signal);
} catch (cause: unknown) {
throw asarFailure(this.path, "inventory", cause);
@@ -202,6 +216,18 @@ export class AsarArtifactReader implements ArtifactReader {
offset === undefined || headerSize === undefined
? undefined
: 8 + headerSize + offset;
if (facts.size === null)
throw new ArtifactEntryContentUnavailable(
`ASAR entry declares no readable size: ${producedEntry.path}`,
"ASAR packed entry bytes were not read because its declared size cannot bound them; no content hash or child artifact was produced.",
undefined,
artifactIntegrityDetails(
producedEntry.path,
producedEntry.declaredIntegrity,
null,
false,
),
);
if (
archiveSize === undefined ||
start === undefined ||
Loaded 100 of 418 files, more files were not shown because too many files have changed in this diff. Show more