Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41a2e84647 | ||
|
|
6a7f70fd54 | ||
|
|
ff56f78594 | ||
|
|
679658b287 | ||
|
|
a6c3d3a365 | ||
|
|
d82ad1faf0 | ||
|
|
2dc89f81be | ||
|
|
30128a03cf | ||
|
|
e049ef70f7 | ||
|
|
bbf0638e88 | ||
|
|
242e2b0001 | ||
|
|
20e4f83097 | ||
|
|
97aff28201 | ||
|
|
641a8395ee |
@@ -2,7 +2,7 @@
|
||||
name: reverse-engineer-anything
|
||||
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. 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: "41"
|
||||
version: "42"
|
||||
---
|
||||
|
||||
# REA
|
||||
@@ -104,6 +104,10 @@ explicit path or endpoint and do not need it.
|
||||
consult the NativeAOT workflow in `references/native-and-artifacts.md` for
|
||||
optional metadata recovery and its supported host/layout boundary.
|
||||
- User-owned browser page already open: `list_browser_targets`.
|
||||
- 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
|
||||
and the focused follow-up browser tools.
|
||||
- Fiddler Everywhere traffic or a native `.saz` capture: read the
|
||||
[Fiddler workflow](references/runtime-observation.md#fiddler-everywhere-traffic-debugging).
|
||||
Inspect saved SAZ with `inspect_web_network_capture` and `format: "saz"`;
|
||||
@@ -323,7 +327,8 @@ as complete while required questions remain open.
|
||||
[references/javascript-applications.md](references/javascript-applications.md)
|
||||
- Android APK declarations, classes, methods, and static references:
|
||||
[references/android-applications.md](references/android-applications.md)
|
||||
- Passive browser/Electron observation and static/runtime reconciliation:
|
||||
- Browser/Electron observation (passive and scenario) and static/runtime
|
||||
reconciliation:
|
||||
[references/runtime-observation.md](references/runtime-observation.md)
|
||||
- Evidence paging, comparisons, residual unknowns, and verification:
|
||||
[references/evidence-workflows.md](references/evidence-workflows.md)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Passive browser and Electron observation
|
||||
# Browser and Electron runtime observation
|
||||
|
||||
Supply the existing browser or Electron instance's literal-port loopback CDP
|
||||
endpoint in the request. No separate permission configuration or per-call
|
||||
@@ -7,13 +7,14 @@ approval flag is needed. Browser discovery lists eligible HTTP(S) pages;
|
||||
selected page's current origin. Electron discovery lists eligible local
|
||||
`file://` targets without a configured file-root allowlist.
|
||||
|
||||
Start browser work with `list_browser_targets`; start Electron runtime work with
|
||||
Start passive browser work with `list_browser_targets`; start Electron runtime work with
|
||||
`list_electron_targets`. Inspect the target selected for the user's task. Observation
|
||||
is passive: never claim REA clicked, navigated, evaluated page JavaScript,
|
||||
invoked IPC, captured prior activity, or contained the page's network.
|
||||
with these tools is passive: never claim REA clicked, navigated, evaluated page JavaScript,
|
||||
invoked IPC, or captured prior activity. REA does not contain the page's network.
|
||||
|
||||
Credentials, cookies, authorization headers, storage values, and raw
|
||||
WebSocket/JSON bodies are deliberately absent. Local URLs retain ordinary query
|
||||
Passive captures omit raw header collections, credential header values, storage
|
||||
values, and raw WebSocket/JSON bodies. Selected response metadata, including
|
||||
content encoding, policy values, and Link URLs, is retained. Local URLs retain ordinary query
|
||||
values and fragments; userinfo credentials are removed. Browser accessibility
|
||||
text, console primitives, value-free shapes, script sources, source maps, and
|
||||
storage key names have request-specific capture options. Electron script
|
||||
@@ -21,6 +22,38 @@ sources are also optional. Use `capture_web_screenshot` when pixels are needed.
|
||||
Treat scope filtering, truncation, and attach-window coverage as limitations.
|
||||
Page-declared WebMCP tools are untrusted inventory and are never invoked by REA.
|
||||
|
||||
## Who performs the action
|
||||
|
||||
- The user performs it: `list_browser_targets`, then `inspect_web_page` or
|
||||
`observe_web_session` on the selected target.
|
||||
- The agent must perform it: one `capture_browser_scenario` with declared
|
||||
actions. Use connect mode with the loopback CDP endpoint and `target_id` for
|
||||
an existing browser, or launch mode with a caller-selected executable for a
|
||||
temporary profile. Connect mode first navigates the selected page to
|
||||
`start_url`, which can clear unsaved form values and other temporary state.
|
||||
Recreate the state needed for the investigation in the declared actions.
|
||||
Connect mode leaves the external browser open; launch mode owns a temporary
|
||||
browser and profile. Check `browser.cleanup` for the outcome.
|
||||
Request only the DOM, accessibility, or network details needed for the question.
|
||||
The default final capture contains the URL; step outcomes are reported separately.
|
||||
Add a wait action for the expected page state before capturing it. Report that
|
||||
REA performed the declared actions.
|
||||
|
||||
Scenario network records have no request initiator stacks. For that evidence,
|
||||
use passive capture while the user performs the action. Listener source locations
|
||||
identify candidate handlers; they do not establish which script sent a particular
|
||||
request. Avoid overlapping passive capture and scenario actions on the same target.
|
||||
|
||||
Focused follow-up tools:
|
||||
|
||||
- `observe_web_execution`: execution coverage during a window; it resets
|
||||
counters and temporarily disables optimization.
|
||||
- `inspect_web_event_listeners`: source locations for listeners registered
|
||||
directly on the first CSS match, without dispatching an event.
|
||||
- `compare_web_captures`: two normalized passive captures or recorded scenarios.
|
||||
- `discover_webmcp_tools`: inspect page-declared WebMCP inventory; never invoke
|
||||
the discovered tools.
|
||||
|
||||
Use `reconcile_javascript_runtime` only after static application Evidence and
|
||||
passive browser/Electron Evidence exist. Prefer captured-source digest identity;
|
||||
report path/digest disagreement as a mismatch and preserve ambiguity. Explicit
|
||||
|
||||
@@ -81,6 +81,7 @@ import ghidra.program.model.listing.Function;
|
||||
import ghidra.program.model.listing.FunctionIterator;
|
||||
import ghidra.program.model.listing.Instruction;
|
||||
import ghidra.program.model.listing.InstructionIterator;
|
||||
import ghidra.program.model.listing.Listing;
|
||||
import ghidra.program.model.listing.Variable;
|
||||
import ghidra.program.model.mem.MemoryBlock;
|
||||
import ghidra.program.model.mem.MemoryBlockSourceInfo;
|
||||
@@ -405,7 +406,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
case "xrefs" -> xrefs(request.params);
|
||||
case "analyze_function" -> {
|
||||
FunctionAnalysisObservation observation = analyzeFunctionObservation(request.params);
|
||||
yield functionObservation(observation.dossier(), observation.limitations());
|
||||
JsonObject result = functionObservation(observation.dossier(), observation.limitations());
|
||||
result.add("relationship_coverage", observation.relationshipCoverage());
|
||||
yield result;
|
||||
}
|
||||
case "set_address_name", "set_addresses_names" -> {
|
||||
if (!descriptor.transport.equals("unix-socket"))
|
||||
@@ -1016,12 +1019,19 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
for (Function item : ordered) {
|
||||
result.add(canonicalAddress(item.getEntryPoint()));
|
||||
}
|
||||
return functionObservation(
|
||||
result,
|
||||
noReturnCallLimitations(
|
||||
if (callers) {
|
||||
JsonObject observation = functionObservation(result, noReturnCallLimitations(
|
||||
currentProgram.getListing().getInstructions(function.getBody(), true)
|
||||
)
|
||||
));
|
||||
observation.add("relationship_coverage", callRelationshipCoverage(null,
|
||||
unrepresentedIncomingCalls(function, observed)));
|
||||
monitor.checkCancelled();
|
||||
return observation;
|
||||
}
|
||||
CallBoundaryObservation calls = callBoundaryObservation(
|
||||
function, currentProgram.getListing().getInstructions(function.getBody(), true), observed, false, null
|
||||
);
|
||||
return callRelationshipObservation(result, calls);
|
||||
}
|
||||
|
||||
private JsonObject procedureInfo(JsonObject params) throws Exception {
|
||||
@@ -1071,18 +1081,10 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
result.addProperty("direction", direction);
|
||||
result.add("references", edges);
|
||||
result.addProperty("reference_kinds_available", true);
|
||||
JsonArray unresolvedCalls = new JsonArray();
|
||||
if (direction.equals("outgoing")) for (Instruction instruction : scanInstructions(function, Integer.MAX_VALUE).instructions) {
|
||||
if (!instruction.getFlowType().isCall()) continue;
|
||||
boolean resolved = false;
|
||||
for (Reference reference : instruction.getReferencesFrom()) if (reference.getReferenceType().isCall()) resolved = true;
|
||||
if (!resolved) {
|
||||
JsonObject site = new JsonObject();
|
||||
site.addProperty("address", canonicalAddress(instruction.getAddress()));
|
||||
site.addProperty("reason", "Instruction decoder identifies a call but ReferenceManager supplies no call destination");
|
||||
unresolvedCalls.add(site);
|
||||
}
|
||||
}
|
||||
JsonArray unresolvedCalls = direction.equals("outgoing")
|
||||
? callBoundaryObservation(
|
||||
function, currentProgram.getListing().getInstructions(function.getBody(), true), null, true, references
|
||||
).unresolvedCalls() : new JsonArray();
|
||||
result.add("unresolved_calls", unresolvedCalls);
|
||||
return result;
|
||||
}
|
||||
@@ -1490,16 +1492,28 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
InstructionScan scan = scanInstructions(function, Integer.MAX_VALUE);
|
||||
DecompileResults decompilation = decompile(function);
|
||||
String pseudocode = decompiledText(decompilation);
|
||||
List<Reference> incomingReferences = collectReferences(function, "incoming")
|
||||
.stream()
|
||||
JsonObject availability = new JsonObject();
|
||||
availability.add("pseudocode", facetAvailability(
|
||||
pseudocode != null,
|
||||
function.isExternal() ? "Ghidra does not decompile external functions."
|
||||
: function.getBody().isEmpty() ? "Ghidra has no function body to decompile."
|
||||
: "Ghidra completed decompilation without returning pseudocode."
|
||||
));
|
||||
if (pseudocode == null) {
|
||||
pseudocode = "";
|
||||
}
|
||||
List<Reference> bodyIncoming = collectReferences(function, "incoming");
|
||||
List<Reference> incomingReferences = bodyIncoming.stream()
|
||||
.filter(reference -> !function.getBody().contains(reference.getFromAddress()))
|
||||
.toList();
|
||||
List<Reference> outgoingReferences = collectReferences(function, "outgoing");
|
||||
JsonArray incoming = referenceEdges(incomingReferences);
|
||||
JsonArray outgoing = referenceEdges(outgoingReferences);
|
||||
JsonArray comments = comments(scan.instructions);
|
||||
JsonArray callers = procedureIdentities(function.getCallingFunctions(monitor));
|
||||
JsonArray callees = procedureIdentities(function.getCalledFunctions(monitor));
|
||||
JsonArray comments = comments(function);
|
||||
Set<Function> callerFunctions = function.getCallingFunctions(monitor);
|
||||
JsonArray callers = procedureIdentities(callerFunctions);
|
||||
Set<Function> calleeFunctions = function.getCalledFunctions(monitor);
|
||||
JsonArray callees = procedureIdentities(calleeFunctions);
|
||||
JsonArray referencedStrings = referencedStrings(outgoingReferences);
|
||||
JsonArray referencedNames = referencedNames(outgoingReferences);
|
||||
JsonArray blocks = basicBlockValues(function);
|
||||
@@ -1511,7 +1525,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
procedure.add("locals", functionLocals(function));
|
||||
JsonObject result = new JsonObject();
|
||||
result.add("procedure", procedure);
|
||||
result.addProperty("pseudocode", pseudocode == null ? "" : pseudocode);
|
||||
result.addProperty("pseudocode", pseudocode);
|
||||
result.add("assembly", assembly);
|
||||
result.add("comments", comments);
|
||||
result.add("callers", callers);
|
||||
@@ -1520,20 +1534,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
result.add("outgoing_references", outgoing);
|
||||
result.add("referenced_strings", referencedStrings);
|
||||
result.add("referenced_names", referencedNames);
|
||||
JsonObject facets = new JsonObject();
|
||||
JsonObject pseudocodeAvailability = new JsonObject();
|
||||
pseudocodeAvailability.addProperty("available", pseudocode != null);
|
||||
if (pseudocode == null) {
|
||||
pseudocodeAvailability.addProperty("reason", "Ghidra did not supply pseudocode.");
|
||||
}
|
||||
facets.add("pseudocode", pseudocodeAvailability);
|
||||
for (String facet : List.of("assembly", "comments", "callers", "callees",
|
||||
"references", "strings_names", "cfg")) {
|
||||
JsonObject available = new JsonObject();
|
||||
available.addProperty("available", true);
|
||||
facets.add(facet, available);
|
||||
}
|
||||
result.add("facet_availability", facets);
|
||||
boolean swift = swiftProgram();
|
||||
boolean aarch64 = currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:");
|
||||
SwiftStringLiteralDecode swiftLiterals = swift && aarch64 ? swiftStringLiterals(function) : null;
|
||||
@@ -1547,6 +1547,15 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
nativeApiBoundary(function, decompilation)
|
||||
);
|
||||
result.add("native_value_flow", nativeValueFlow(decompilation));
|
||||
// Successful full queries distinguish collected empty sets from missing facts.
|
||||
// Availability does not claim that Ghidra resolved every runtime relationship.
|
||||
for (String facet : List.of("comments", "callers", "callees", "references", "strings_names", "cfg")) {
|
||||
availability.add(facet, facetAvailability(true, null));
|
||||
}
|
||||
availability.add("assembly", facetAvailability(
|
||||
!scan.truncated(), "The function instruction scan was truncated."
|
||||
));
|
||||
result.add("facet_availability", availability);
|
||||
JsonArray limitations = new JsonArray();
|
||||
limitations.add(
|
||||
"Unresolved computed or indirect flows without target addresses are not represented as reference edges."
|
||||
@@ -1574,8 +1583,10 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
"Swift String literal decoding is implemented for AArch64 register pairs only; no Swift literals were decoded for this architecture."
|
||||
);
|
||||
}
|
||||
List<String> boundaryLimitations = noReturnCallLimitations(scan.instructions);
|
||||
for (String limitation : boundaryLimitations) {
|
||||
CallBoundaryObservation calls = callBoundaryObservation(
|
||||
function, scan.instructions, calleeFunctions, true, outgoingReferences);
|
||||
result.add("unresolved_calls", calls.unresolvedCalls());
|
||||
for (String limitation : calls.limitations()) {
|
||||
limitations.add(limitation);
|
||||
}
|
||||
if (decompilation != null && decompilation.getErrorMessage() != null &&
|
||||
@@ -1583,7 +1594,14 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
limitations.add("Ghidra decompiler diagnostic: " + decompilation.getErrorMessage());
|
||||
}
|
||||
result.add("limitations", limitations);
|
||||
return new FunctionAnalysisObservation(result, boundaryLimitations);
|
||||
monitor.checkCancelled();
|
||||
int missingCallers = 0;
|
||||
for (Reference reference : bodyIncoming) {
|
||||
monitor.checkCancelled();
|
||||
if (unrepresentedCaller(reference, callerFunctions)) missingCallers++;
|
||||
}
|
||||
return new FunctionAnalysisObservation(result, calls.limitations(),
|
||||
callRelationshipCoverage(calls, missingCallers));
|
||||
}
|
||||
|
||||
private JsonObject nativeValueFlow(DecompileResults decompilation) throws Exception {
|
||||
@@ -3287,54 +3305,202 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
return result;
|
||||
}
|
||||
|
||||
private static JsonObject facetAvailability(boolean available, String reason) {
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("available", available);
|
||||
if (!available) result.addProperty("reason", reason);
|
||||
return result;
|
||||
}
|
||||
|
||||
private JsonObject callRelationshipObservation(JsonElement value, CallBoundaryObservation calls)
|
||||
throws Exception {
|
||||
monitor.checkCancelled();
|
||||
JsonObject result = functionObservation(value, calls.limitations());
|
||||
result.add("relationship_coverage", callRelationshipCoverage(calls, null));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static JsonObject callRelationshipCoverage(
|
||||
CallBoundaryObservation calls, Integer missingCallers) {
|
||||
JsonObject coverage = completeResolvedCallCoverage();
|
||||
Integer omitted = calls == null ? null : calls.unrepresentedCalleeCalls();
|
||||
JsonArray directions = new JsonArray();
|
||||
if (missingCallers != null && missingCallers > 0) directions.add("callers");
|
||||
String callerLimitation = missingCallers != null && missingCallers > 0
|
||||
? "Ghidra's caller inventory omits sources of " + missingCallers +
|
||||
" call reference(s) into this function's body." : null;
|
||||
if ((calls != null && omitted == null) || (calls == null && missingCallers == null)) {
|
||||
coverage.addProperty("status", "unknown");
|
||||
String reason = calls == null ? "No call inventory was examined."
|
||||
: "The callee inventory was not examined alongside decoded call targets.";
|
||||
coverage.addProperty("reason", reason + (callerLimitation == null ? "" : " " + callerLimitation));
|
||||
if (calls == null && missingCallers == null) directions.add("callers");
|
||||
directions.add("callees");
|
||||
coverage.add("limited_directions", directions);
|
||||
return coverage;
|
||||
}
|
||||
JsonArray limitations = new JsonArray();
|
||||
if (callerLimitation != null) limitations.add(callerLimitation);
|
||||
if (omitted != null && omitted > 0) {
|
||||
limitations.add("Ghidra's callee inventory omits targets of " + omitted +
|
||||
" call reference(s) or decoded call(s) without destinations.");
|
||||
directions.add("callees");
|
||||
}
|
||||
if (!limitations.isEmpty()) {
|
||||
coverage.addProperty("status", "partial");
|
||||
coverage.add("limitations", limitations);
|
||||
coverage.add("limited_directions", directions);
|
||||
}
|
||||
return coverage;
|
||||
}
|
||||
|
||||
private static JsonObject completeResolvedCallCoverage() {
|
||||
JsonObject coverage = new JsonObject();
|
||||
coverage.addProperty("status", "complete");
|
||||
JsonArray diagnostics = new JsonArray();
|
||||
diagnostics.add(
|
||||
"Only resolved Ghidra call references are returned; unresolved computed or indirect calls remain unknown, while function classifications distinguish thunks and externals."
|
||||
);
|
||||
coverage.add("diagnostics", diagnostics);
|
||||
return coverage;
|
||||
}
|
||||
|
||||
private boolean unrepresentedCaller(Reference reference, Set<Function> callers) {
|
||||
if (!reference.getReferenceType().isCall()) return false;
|
||||
Function caller = containingFunction(reference.getFromAddress());
|
||||
return caller == null || !callers.contains(caller);
|
||||
}
|
||||
|
||||
private int unrepresentedIncomingCalls(Function function, Set<Function> callers)
|
||||
throws Exception {
|
||||
int[] omitted = { 0 };
|
||||
visitReferences(function, "incoming", reference -> {
|
||||
if (unrepresentedCaller(reference, callers)) omitted[0]++;
|
||||
});
|
||||
return omitted[0];
|
||||
}
|
||||
|
||||
private List<String> noReturnCallLimitations(Iterable<Instruction> instructions)
|
||||
throws Exception {
|
||||
List<String> limitations = new ArrayList<>();
|
||||
for (Instruction instruction : instructions) {
|
||||
monitor.checkCancelled();
|
||||
if (!instruction.getFlowType().isCall() || !instruction.getFlowType().isTerminal()) {
|
||||
continue;
|
||||
}
|
||||
Address[] destinations = instruction.getFlows();
|
||||
StringBuilder limitation = new StringBuilder();
|
||||
limitation.append("Ghidra Listing reports a terminal call at ")
|
||||
.append(canonicalAddress(instruction.getAddress()));
|
||||
if (destinations.length == 0) {
|
||||
limitation.append(" with no resolved callee; its no-return basis is unknown.");
|
||||
}
|
||||
else {
|
||||
limitation.append(" to ");
|
||||
for (int index = 0; index < destinations.length; index++) {
|
||||
if (index > 0) limitation.append(", ");
|
||||
Address destination = destinations[index];
|
||||
limitation.append(canonicalAddress(destination));
|
||||
Function target = currentProgram.getFunctionManager().getFunctionAt(destination);
|
||||
if (target == null) {
|
||||
limitation.append(" (callee identity unknown)");
|
||||
}
|
||||
else {
|
||||
limitation.append(" ").append(procedureName(target));
|
||||
limitation.append(" (external=").append(target.isExternal());
|
||||
limitation.append(", thunk=").append(target.isThunk());
|
||||
limitation.append(", hasNoReturn=").append(target.hasNoReturn()).append(")");
|
||||
}
|
||||
}
|
||||
limitation.append("; fallthrough is excluded by Ghidra's flow model.");
|
||||
Function target = destinations.length == 1
|
||||
? currentProgram.getFunctionManager().getFunctionAt(destinations[0])
|
||||
: null;
|
||||
if (target != null && target.hasNoReturn()) {
|
||||
limitation.append(" This records Ghidra's FunctionManager flag, not an independent verification that the callee cannot return.");
|
||||
}
|
||||
else {
|
||||
limitation.append(" The reason for the terminal flow is not established by this observation.");
|
||||
}
|
||||
}
|
||||
limitations.add(limitation.toString());
|
||||
String limitation = terminalCallLimitation(instruction);
|
||||
if (limitation != null) limitations.add(limitation);
|
||||
}
|
||||
return limitations;
|
||||
}
|
||||
|
||||
private CallBoundaryObservation callBoundaryObservation(
|
||||
Function function, Iterable<Instruction> instructions, Set<Function> returnedCallees,
|
||||
boolean includeUnresolvedSites, List<Reference> observedReferences)
|
||||
throws Exception {
|
||||
List<String> limitations = new ArrayList<>();
|
||||
CallBoundaryReferences calls = new CallBoundaryReferences(returnedCallees, includeUnresolvedSites);
|
||||
if (observedReferences == null) visitReferences(function, "outgoing", calls::observe);
|
||||
else for (Reference reference : observedReferences) {
|
||||
monitor.checkCancelled();
|
||||
calls.observe(reference);
|
||||
}
|
||||
for (Instruction instruction : instructions) {
|
||||
monitor.checkCancelled();
|
||||
String limitation = terminalCallLimitation(instruction);
|
||||
if (limitation != null) limitations.add(limitation);
|
||||
if (!instruction.getFlowType().isCall()) continue;
|
||||
boolean destination = false;
|
||||
for (Reference reference : instruction.getReferencesFrom()) {
|
||||
if (!reference.getReferenceType().isCall()) continue;
|
||||
destination = true;
|
||||
break;
|
||||
}
|
||||
if (!destination) {
|
||||
calls.unrepresented++;
|
||||
calls.unresolved(instruction.getAddress(),
|
||||
"Instruction decoder identifies a call but ReferenceManager supplies no call destination");
|
||||
}
|
||||
}
|
||||
return new CallBoundaryObservation(List.copyOf(limitations),
|
||||
returnedCallees == null ? null : calls.unrepresented, calls.unresolvedCalls());
|
||||
}
|
||||
|
||||
private final class CallBoundaryReferences {
|
||||
private final Set<Function> returnedCallees;
|
||||
private final TreeMap<Address, String> unresolved;
|
||||
private int unrepresented;
|
||||
|
||||
CallBoundaryReferences(Set<Function> returnedCallees, boolean includeUnresolvedSites) {
|
||||
this.returnedCallees = returnedCallees;
|
||||
unresolved = includeUnresolvedSites ? new TreeMap<>() : null;
|
||||
}
|
||||
|
||||
void observe(Reference reference) {
|
||||
if (!reference.getReferenceType().isCall()) return;
|
||||
// Reference identity follows the same containing-function owner as referenceEdge.
|
||||
Function target = containingFunction(reference.getToAddress());
|
||||
if (returnedCallees != null && (target == null || !returnedCallees.contains(target)))
|
||||
unrepresented++;
|
||||
if (target == null) unresolved(reference.getFromAddress(),
|
||||
"A call destination has no known containing Ghidra procedure");
|
||||
}
|
||||
|
||||
void unresolved(Address address, String reason) {
|
||||
if (unresolved != null) unresolved.put(address, reason);
|
||||
}
|
||||
|
||||
JsonArray unresolvedCalls() {
|
||||
if (unresolved == null) return null;
|
||||
JsonArray result = new JsonArray();
|
||||
for (var item : unresolved.entrySet()) {
|
||||
JsonObject site = new JsonObject();
|
||||
site.addProperty("address", canonicalAddress(item.getKey()));
|
||||
site.addProperty("reason", item.getValue());
|
||||
result.add(site);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private String terminalCallLimitation(Instruction instruction) {
|
||||
if (!instruction.getFlowType().isCall() || !instruction.getFlowType().isTerminal())
|
||||
return null;
|
||||
Address[] destinations = instruction.getFlows();
|
||||
StringBuilder limitation = new StringBuilder();
|
||||
limitation.append("Ghidra Listing reports a terminal call at ")
|
||||
.append(canonicalAddress(instruction.getAddress()));
|
||||
if (destinations.length == 0) {
|
||||
limitation.append(" with no resolved callee; its no-return basis is unknown.");
|
||||
}
|
||||
else {
|
||||
limitation.append(" to ");
|
||||
for (int index = 0; index < destinations.length; index++) {
|
||||
if (index > 0) limitation.append(", ");
|
||||
Address destination = destinations[index];
|
||||
limitation.append(canonicalAddress(destination));
|
||||
Function target = currentProgram.getFunctionManager().getFunctionAt(destination);
|
||||
if (target == null) {
|
||||
limitation.append(" (callee identity unknown)");
|
||||
}
|
||||
else {
|
||||
limitation.append(" ").append(procedureName(target));
|
||||
limitation.append(" (external=").append(target.isExternal());
|
||||
limitation.append(", thunk=").append(target.isThunk());
|
||||
limitation.append(", hasNoReturn=").append(target.hasNoReturn()).append(")");
|
||||
}
|
||||
}
|
||||
limitation.append("; fallthrough is excluded by Ghidra's flow model.");
|
||||
Function target = destinations.length == 1
|
||||
? currentProgram.getFunctionManager().getFunctionAt(destinations[0])
|
||||
: null;
|
||||
if (target != null && target.hasNoReturn()) {
|
||||
limitation.append(" This records Ghidra's FunctionManager flag, not an independent verification that the callee cannot return.");
|
||||
}
|
||||
else {
|
||||
limitation.append(" The reason for the terminal flow is not established by this observation.");
|
||||
}
|
||||
}
|
||||
return limitation.toString();
|
||||
}
|
||||
|
||||
private static String renderAssembly(List<Instruction> instructions) {
|
||||
return String.join("\n", renderAssemblyLines(instructions));
|
||||
}
|
||||
@@ -3358,15 +3524,27 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
private List<Reference> collectReferences(
|
||||
Function function,
|
||||
String direction) throws Exception {
|
||||
TreeMap<String, Reference> observed = new TreeMap<>();
|
||||
visitReferences(function, direction, reference -> addReference(observed, reference));
|
||||
List<Reference> result = new ArrayList<>(observed.values());
|
||||
result.sort(REFERENCE_ORDER);
|
||||
return result;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface ReferenceVisitor {
|
||||
void accept(Reference reference) throws Exception;
|
||||
}
|
||||
|
||||
private void visitReferences(Function function, String direction, ReferenceVisitor visitor)
|
||||
throws Exception {
|
||||
// References can target instruction interiors or originate in embedded
|
||||
// data. Use actual body ownership, including an external's exact entry.
|
||||
AddressSet scope = new AddressSet(function.getBody());
|
||||
scope.add(function.getEntryPoint());
|
||||
AddressSet scope = functionObservationScope(function);
|
||||
boolean outgoing = direction.equals("outgoing");
|
||||
AddressIterator addresses = outgoing
|
||||
? currentProgram.getReferenceManager().getReferenceSourceIterator(scope, true)
|
||||
: currentProgram.getReferenceManager().getReferenceDestinationIterator(scope, true);
|
||||
TreeMap<String, Reference> observed = new TreeMap<>();
|
||||
while (addresses.hasNext()) {
|
||||
monitor.checkCancelled();
|
||||
Address address = addresses.next();
|
||||
@@ -3374,7 +3552,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
for (Reference reference : currentProgram.getReferenceManager()
|
||||
.getReferencesFrom(address)) {
|
||||
monitor.checkCancelled();
|
||||
addReference(observed, reference);
|
||||
if (!reference.isEntryPointReference()) visitor.accept(reference);
|
||||
}
|
||||
}
|
||||
else {
|
||||
@@ -3382,19 +3560,14 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
.getReferencesTo(address);
|
||||
while (iterator.hasNext()) {
|
||||
monitor.checkCancelled();
|
||||
addReference(observed, iterator.next());
|
||||
Reference reference = iterator.next();
|
||||
if (!reference.isEntryPointReference()) visitor.accept(reference);
|
||||
}
|
||||
}
|
||||
}
|
||||
List<Reference> result = new ArrayList<>(observed.values());
|
||||
result.sort(REFERENCE_ORDER);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void addReference(TreeMap<String, Reference> observed, Reference reference) {
|
||||
if (reference.isEntryPointReference()) {
|
||||
return;
|
||||
}
|
||||
String key = canonicalAddress(reference.getFromAddress()) + "\u0000" +
|
||||
canonicalAddress(reference.getToAddress()) + "\u0000" +
|
||||
reference.getReferenceType().getName() + "\u0000" +
|
||||
@@ -3403,6 +3576,12 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
observed.putIfAbsent(key, reference);
|
||||
}
|
||||
|
||||
private static AddressSet functionObservationScope(Function function) {
|
||||
AddressSet scope = new AddressSet(function.getBody());
|
||||
scope.add(function.getEntryPoint());
|
||||
return scope;
|
||||
}
|
||||
|
||||
private JsonArray referenceEdges(List<Reference> references) {
|
||||
JsonArray result = new JsonArray();
|
||||
for (Reference reference : references) {
|
||||
@@ -3556,23 +3735,28 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
return result;
|
||||
}
|
||||
|
||||
private static JsonArray comments(List<Instruction> instructions) {
|
||||
private JsonArray comments(Function function) throws Exception {
|
||||
Listing listing = currentProgram.getListing();
|
||||
AddressSet scope = functionObservationScope(function);
|
||||
AddressIterator addresses = listing.getCommentAddressIterator(scope, true);
|
||||
TreeMap<String, JsonObject> observed = new TreeMap<>();
|
||||
for (Instruction instruction : instructions) {
|
||||
while (addresses.hasNext()) {
|
||||
monitor.checkCancelled();
|
||||
Address address = addresses.next();
|
||||
for (CommentType type : CommentType.values()) {
|
||||
String text = instruction.getComment(type);
|
||||
String text = listing.getComment(type, address);
|
||||
if (text == null || text.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
String kind = type == CommentType.EOL ? "inline" : "comment";
|
||||
JsonObject item = new JsonObject();
|
||||
item.addProperty("address", canonicalAddress(instruction.getAddress()));
|
||||
item.addProperty("address", canonicalAddress(address));
|
||||
item.addProperty("kind", kind);
|
||||
item.addProperty("text", text);
|
||||
String key = canonicalAddress(instruction.getAddress()) + "\u0000" +
|
||||
String key = canonicalAddress(address) + "\u0000" +
|
||||
kind + "\u0000" + type.name() + "\u0000" + text;
|
||||
observed.put(key, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
JsonArray result = new JsonArray();
|
||||
observed.values().forEach(result::add);
|
||||
@@ -4508,7 +4692,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
private record InventoryItem(Address address, String value, JsonObject facts) {}
|
||||
private record AddressNameRequest(String inputAddress, Address address, String name) {}
|
||||
private record FunctionEntry(Function function, InventoryItem item) {}
|
||||
private record FunctionAnalysisObservation(JsonObject dossier, List<String> limitations) {}
|
||||
private record FunctionAnalysisObservation(JsonObject dossier, List<String> limitations, JsonObject relationshipCoverage) {}
|
||||
private record CallBoundaryObservation(List<String> limitations, Integer unrepresentedCalleeCalls, JsonArray unresolvedCalls) {}
|
||||
private record SwiftStringLiteralDecode(JsonArray literals, JsonArray omissions) {}
|
||||
private record SwiftLiteralLayout(
|
||||
String representation,
|
||||
|
||||
@@ -265,6 +265,21 @@ The user-level MCP paths follow the editor's VS Code layout. Trae's official
|
||||
documentation describes the raw JSON editor without specifying those paths;
|
||||
loading this registration in Trae remains unverified.
|
||||
|
||||
## WorkBuddy
|
||||
|
||||
For [WorkBuddy](https://www.workbuddy.cn/docs/workbuddy/From-Beginner-to-Expert-Guide/Function-Description/MCP-Guide), setup writes `mcpServers.rea` into the
|
||||
global `~/.workbuddy/mcp.json` (a standard `mcpServers` JSON file at the user
|
||||
home root on every platform). Entries carry `command`, `args`, and `env`;
|
||||
stdio is the default transport. WorkBuddy discovers personal skills from
|
||||
`~/.workbuddy/skills` (`%userprofile%\.workbuddy\skills` on Windows), so
|
||||
setup installs its skill copy there.
|
||||
|
||||
The global MCP path is a plain JSON file; setup preserves every other server
|
||||
and top-level key when it upserts `rea`. WorkBuddy also reads a project-level
|
||||
`<project>/.workbuddy/mcp.json`, which setup does not touch: it registers the
|
||||
user-level file only. Loading this registration and real REA tool calls
|
||||
through WorkBuddy are verified.
|
||||
|
||||
## ZCode
|
||||
|
||||
For [ZCode](https://zcode.z.ai/en/docs/mcp-services), setup registers `rea` in the nested `mcp.servers` table of the
|
||||
@@ -279,7 +294,11 @@ the first native server into an empty table, it copies the fallback's
|
||||
`mcpServers` entries into the native table so they keep loading, and leaves
|
||||
the fallback file untouched. Existing REA environment overrides are preserved
|
||||
when refreshing its command. A malformed or unreadable fallback file fails setup instead of
|
||||
being silently suppressed; repair or remove it and rerun. ZCode disables a
|
||||
being silently suppressed; repair or remove it and rerun. Uninstall removes an
|
||||
REA-owned `rea` entry from both the native table and the fallback, backing up
|
||||
each changed file, so an emptied native table cannot load a leftover fallback
|
||||
registration; other fallback servers, settings and a foreign `rea` entry are
|
||||
kept. ZCode disables a
|
||||
server with `enable: false` on its entry; doctor reports such a registration
|
||||
as stale, and rerunning setup removes the flag to repair it. Native configuration
|
||||
uses strict JSON; setup refuses comments, trailing commas and a BOM rather than
|
||||
|
||||
+159
-3
@@ -129,6 +129,150 @@ Setup pins package-runner MCP registrations to the exact installed REA version,
|
||||
installs the matching skill and on-demand references in the same plan, and adds
|
||||
`startup_timeout_sec = 30` for Codex and Grok Build.
|
||||
|
||||
## Limit REA to one project or agent profile
|
||||
|
||||
Setup registers REA for the whole client. For Claude Code it writes
|
||||
`mcpServers.rea` in `~/.claude.json` and installs the bundled skill under
|
||||
`~/.claude/skills`, so every project that Claude Code opens can call REA. To
|
||||
offer REA's tools only where they're wanted, use one of the routes below.
|
||||
|
||||
Both routes control which sessions discover REA's MCP server and skill. They
|
||||
leave shell commands, filesystem access and network access available. An agent
|
||||
under the same OS account can run `rea` from `PATH` or `npx` and read other
|
||||
profiles and REA output. For clean-room separation, use an implementation
|
||||
environment with filesystem and network restrictions that prevent reading or
|
||||
fetching REA, the target and analysis output, including through shared
|
||||
installations and package registries. A separate account or container must
|
||||
enforce those restrictions.
|
||||
|
||||
For Claude Code, check existing default-profile state before choosing either
|
||||
route. Setup is additive: a previous user-level REA registration and personal
|
||||
skill remain active in other projects. Back up the default configuration and
|
||||
REA skill first, then, with `CLAUDE_CONFIG_DIR` unset, remove the REA user
|
||||
registration with `claude mcp remove --scope user rea` and remove the known
|
||||
REA-installed `~/.claude/skills/reverse-engineer-anything` directory. Preserve
|
||||
unrelated registrations and skills. `rea uninstall` applies to other clients
|
||||
as well, so it is broader than this Claude-only migration.
|
||||
|
||||
**A separate Claude Code profile.** Claude Code keeps settings, skills and session
|
||||
history under `CLAUDE_CONFIG_DIR`. Setup follows the same
|
||||
variable: it writes the registration to `$CLAUDE_CONFIG_DIR/.claude.json` and
|
||||
the skill to `$CLAUDE_CONFIG_DIR/skills`.
|
||||
|
||||
```bash
|
||||
CLAUDE_CONFIG_DIR=~/.claude-rea npx rea-agents setup --client claude_code
|
||||
CLAUDE_CONFIG_DIR=~/.claude-rea claude
|
||||
```
|
||||
|
||||
A new profile may need authentication. Console sign-ins without an API key are
|
||||
stored outside `CLAUDE_CONFIG_DIR`; see
|
||||
[Claude Code authentication](https://code.claude.com/docs/en/authentication#log-in-with-multiple-accounts).
|
||||
Sessions started without the variable use the default profile's registration,
|
||||
skill and history.
|
||||
|
||||
Inline assignments apply only to that command. Keep the same prefix for every
|
||||
profile check, setup rerun, update, or removal; bare commands use the current
|
||||
environment and can otherwise inspect or recreate default-profile state:
|
||||
|
||||
```bash
|
||||
CLAUDE_CONFIG_DIR=~/.claude-rea claude mcp list
|
||||
CLAUDE_CONFIG_DIR=~/.claude-rea npx rea-agents doctor --client claude_code --skill --json
|
||||
CLAUDE_CONFIG_DIR=~/.claude-rea npx -y rea-agents@latest setup --client claude_code --dry-run
|
||||
```
|
||||
|
||||
After reviewing an update plan, rerun that same prefixed setup command without
|
||||
`--dry-run` and approve its exact changes. Profile removal likewise needs the
|
||||
prefix on `claude mcp remove --scope user rea` and any `rea uninstall` command;
|
||||
keep the broader client scope of uninstall in mind. Restart the selected
|
||||
profile after changing its pinned registration.
|
||||
|
||||
`claude mcp remove` removes only the registration. To remove REA from this
|
||||
profile without running the broader uninstall flow, first back up the profile's
|
||||
REA-managed skill directory, then remove
|
||||
`$CLAUDE_CONFIG_DIR/skills/reverse-engineer-anything` as well. Keep other skills
|
||||
in the profile intact.
|
||||
|
||||
The commands above use POSIX shell assignment syntax. In native Windows
|
||||
PowerShell, select the profile for the whole setup/launch/check session and
|
||||
restore the previous selection afterward:
|
||||
|
||||
```powershell
|
||||
$previousClaudeConfigDir = $env:CLAUDE_CONFIG_DIR
|
||||
try {
|
||||
$env:CLAUDE_CONFIG_DIR = Join-Path $HOME ".claude-rea"
|
||||
npx rea-agents setup --client claude_code
|
||||
claude mcp list
|
||||
npx rea-agents doctor --client claude_code --skill --json
|
||||
npx -y rea-agents@latest setup --client claude_code --dry-run
|
||||
# After reviewing the plan, repeat setup without --dry-run and approve it.
|
||||
claude
|
||||
# Profile removal, when intended, also belongs inside this selected scope:
|
||||
# claude mcp remove --scope user rea
|
||||
} finally {
|
||||
if ($null -eq $previousClaudeConfigDir) {
|
||||
Remove-Item Env:CLAUDE_CONFIG_DIR -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$env:CLAUDE_CONFIG_DIR = $previousClaudeConfigDir
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use PowerShell for these Windows examples rather than pasting POSIX assignments
|
||||
into cmd.exe. Keep profile updates, readiness checks and removal inside the
|
||||
same selected environment; the `finally` block restores the previous profile
|
||||
or unsets the variable when it was absent.
|
||||
|
||||
**One project.** Install without configuring Claude Code: use the curl
|
||||
installer's `--no-setup`, or run setup only for other clients. Then register
|
||||
REA from the project directory at Claude Code's local scope.
|
||||
|
||||
This route uses the default Claude profile. Keep `CLAUDE_CONFIG_DIR` unset for
|
||||
the entire local lifecycle: registration, backup, listing, version replacement
|
||||
and removal. In POSIX shells run `unset CLAUDE_CONFIG_DIR`; in PowerShell run
|
||||
`Remove-Item Env:CLAUDE_CONFIG_DIR -ErrorAction SilentlyContinue` before these
|
||||
commands. Record an existing selection first if you intend to restore it after
|
||||
finishing. The configuration path below is the default profile's
|
||||
`~/.claude.json`; a different active profile uses its own config root instead.
|
||||
|
||||
Register with the variable unset:
|
||||
|
||||
<!-- x-release-please-start-version -->
|
||||
|
||||
```bash
|
||||
claude mcp add --scope local rea -- npx -y rea-agents@6.4.0 mcp
|
||||
```
|
||||
|
||||
On native Windows, use the same project directory and wrap the package-runner
|
||||
shim so the stdio client can launch it:
|
||||
|
||||
```powershell
|
||||
claude mcp add --scope local rea -- cmd.exe /d /c npx -y rea-agents@6.4.0 mcp
|
||||
```
|
||||
|
||||
<!-- x-release-please-end -->
|
||||
|
||||
Local scope stores the entry under that directory's path in `~/.claude.json`,
|
||||
so other projects don't load it. This route doesn't install the bundled skill.
|
||||
Avoid `--scope project`, which writes `.mcp.json` into the repository where it
|
||||
can be committed or copied. `rea doctor` and `rea uninstall` read only the
|
||||
user-level entry, so they don't report or remove a local-scope registration;
|
||||
remove it with `claude mcp remove --scope local rea` from the same directory.
|
||||
`rea update` doesn't refresh it either, so it stays on the version it names.
|
||||
To move it to a newer release, first back up the project entry in
|
||||
`~/.claude.json` and record its environment and other metadata. In particular,
|
||||
preserve caller-selected overrides such as `REA_MCP_INPUT_SCHEMA_PROFILE` and
|
||||
`REA_MCP_MAX_RESPONSE_BYTES`. Run `claude mcp remove --scope local rea` from the
|
||||
same directory, then recreate it with the platform-appropriate command above
|
||||
and the new version. Reapply every recorded environment value with Claude
|
||||
Code's `mcp add --env NAME=value` options and restore any other retained entry
|
||||
metadata before restarting the project session. Removal and re-addition do not
|
||||
preserve these values automatically. Running setup for Claude Code instead
|
||||
would add a user-level entry for every project.
|
||||
|
||||
Check the separate profile with the prefixed `claude mcp list` above and compare
|
||||
with the default profile with `CLAUDE_CONFIG_DIR` unset. For local scope, run
|
||||
`claude mcp list` from the selected project and again from another project.
|
||||
|
||||
## Check readiness for your task
|
||||
|
||||
Run `rea doctor` when you need diagnosis. It reads host prerequisites and agent
|
||||
@@ -288,11 +432,12 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
|
||||
| Qoder | `qoder` |
|
||||
| Factory Droid | `droid` |
|
||||
| Trae | `trae` |
|
||||
| WorkBuddy | `workbuddy` |
|
||||
| ZCode | `zcode` |
|
||||
| DeepSeek Harness | `dsh` |
|
||||
|
||||
`dsh` and `kimi` require a release containing them. The registry check above does
|
||||
not cover them.
|
||||
`dsh`, `kimi`, and `workbuddy` require a release containing them. The registry
|
||||
check above does not cover them.
|
||||
|
||||
See [client configuration and troubleshooting](client-configuration.md) for
|
||||
configuration paths, profiles, trust policies, and schema-limit workarounds.
|
||||
@@ -493,7 +638,13 @@ copying license secrets into logs, or killing unrelated Hopper processes.
|
||||
REA connects to an existing Ghidra installation on Linux x64/arm64, macOS x64/arm64,
|
||||
or experimental Windows x64 P0.
|
||||
It accepts Ghidra 12.1.x and the 64-bit full JDK declared by that installation's
|
||||
`application.java.min` and `application.java.max`. Current 12.1 releases require
|
||||
`application.java.min` and `application.java.max`. The JDK's reported platform must also
|
||||
map to the same native decompiler directory as the host: a JDK running under
|
||||
emulation (for example x86_64 under Rosetta on Apple silicon) or reporting a
|
||||
non-host `os.name` fails the doctor
|
||||
even when it is an otherwise accepted 64-bit full JDK. Select a matching JDK
|
||||
through `JAVA_HOME`; adding another decompiler in the JVM-selected directory
|
||||
does not satisfy this host-validated admission rule. Current 12.1 releases require
|
||||
JDK 21 or newer and set no maximum. The bridge is verified with Ghidra 12.1.4
|
||||
and JDK 21. Each installation must include the native decompiler for the host
|
||||
architecture in `Ghidra/Features/Decompiler/os/<platform>/` or the corresponding
|
||||
@@ -556,6 +707,11 @@ registrations. See [Windows Ghidra P0](windows-ghidra-p0.md) for provider diagno
|
||||
Doctor validates the platform, architecture, Ghidra 12.1.x application version,
|
||||
`support/analyzeHeadless` or `support/analyzeHeadless.bat`, the installation's
|
||||
Java major range, 64-bit JDK bitness, and the presence of `javac`/`javac.exe`.
|
||||
It also checks that the reported JVM `os.arch` and `os.name` map to the same native decompiler
|
||||
directory validated for the host; unrecognized reported architectures or a
|
||||
non-host operating system are refused. LaunchSupport overrides of either
|
||||
property, including through helper-environment JVM option variables, are
|
||||
rejected at launch.
|
||||
When Java is found through `PATH`, setup records its observed JDK home so GUI
|
||||
MCP clients do not depend on an incidental shell path. Setup shows every exact
|
||||
environment entry in its plan, writes only after approval, and never downloads,
|
||||
|
||||
+22
-25
@@ -20,7 +20,7 @@
|
||||
|
||||
**[웹사이트](https://rea.tools/ko/) · [가이드](https://rea.tools/ko/guides/) · [사례](https://rea.tools/ko/showcase/)**
|
||||
|
||||
[빠른 시작](#빠른-시작) · [REA의 작동 방식](#rea의-작동-방식) · [분석할 수 있는 대상](#분석할-수-있는-대상) · [사례](#사례) · [자주 묻는 질문](#자주-묻는-질문) · [GNU objdump 및 Rizin 도구](../reverse-engineering-tools.md) · [문서](#문서)
|
||||
[빠른 시작](#빠른-시작) · [REA의 작동 방식](#rea의-작동-방식) · [분석할 수 있는 대상](#분석할-수-있는-대상) · [사례](#사례) · [자주 묻는 질문](#자주-묻는-질문) · [문서](#문서)
|
||||
|
||||
<code>npx rea-agents setup</code>
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
REA는 에이전트를 분석 도구에 연결해 네이티브 바이너리, JavaScript와 Electron 앱, .NET 어셈블리, 웹사이트를 검사할 수 있게 합니다. 터미널에서도 같은 도구를 사용할 수 있습니다. 분석은 로컬에서 실행되며, 결과에는 각 결론의 근거와 한계가 포함됩니다.
|
||||
|
||||
설정 과정은 에이전트에 REA를 등록하고 해당 버전에 맞는 워크플로 지침을 설치합니다. 네이티브 분석에는 기존 Hopper 또는 Ghidra 설치를 사용할 수 있습니다. 승인하면 설정 과정에서 Hopper를 설치할 수도 있습니다. 정적 JavaScript 분석에는 두 엔진 모두 필요하지 않습니다.
|
||||
설정 과정은 에이전트에 REA를 등록하고 해당 버전에 맞는 워크플로 지침을 설치합니다. 네이티브 분석에는 기존 Hopper, Ghidra 또는 IDA 설치를 사용할 수 있습니다. 승인하면 설정 과정에서 Hopper를 설치할 수도 있습니다. 정적 JavaScript 분석에는 네이티브 분석 엔진이 필요하지 않습니다.
|
||||
|
||||
> **[REA 웹사이트](https://rea.tools/ko/)**에서 설정 방법, 그림으로 설명한 가이드, 실제 사례를 확인하세요.
|
||||
|
||||
@@ -105,12 +105,14 @@ rea --help
|
||||
|
||||
REA는 빠르게 바뀌며 새 릴리스에는 버그 수정이 자주 포함됩니다. 설치된 버전을 최신으로 유지하세요.
|
||||
|
||||
npm으로 설치한 CLI에서는 다음 명령을 사용합니다.
|
||||
`npm install --global` 또는 `bun add --global`로 설치한 CLI에서는 다음 명령을 사용합니다.
|
||||
|
||||
```bash
|
||||
rea update
|
||||
```
|
||||
|
||||
Bun 전역 업데이트에는 Bun 1.2.17 이상이 필요합니다. 이전 버전의 Bun을 사용한다면 `rea update`를 실행하기 전에 Bun을 직접 업그레이드하세요.
|
||||
|
||||
에이전트 등록과 스킬을 갱신하려면 업데이트 과정에서 출력된 설정 명령을 실행하세요.
|
||||
|
||||
`npx`를 사용한다면 다음 명령으로 에이전트 설정을 갱신하세요.
|
||||
@@ -119,7 +121,7 @@ rea update
|
||||
npx rea-agents@latest setup
|
||||
```
|
||||
|
||||
설정 변경 사항을 검토하고 에이전트를 다시 시작하세요. 일회성 CLI 작업에는 `npx rea-agents@latest` 뒤에 필요한 명령을 붙여 실행하세요.
|
||||
설정 변경 사항을 검토하고 에이전트를 다시 시작하세요. 일회성 CLI 작업에는 `npx rea-agents@latest` 뒤에 필요한 명령을 붙여 실행하세요. Bun을 사용한다면 위 명령의 `npx rea-agents@latest`를 `bunx rea-agents@latest`로 바꿔 실행하세요.
|
||||
|
||||
## REA의 작동 방식
|
||||
|
||||
@@ -139,14 +141,16 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니
|
||||
| --------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 네이티브 바이너리 | 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조 | Hopper, Ghidra 또는 IDA; [네이티브 분석](https://rea.tools/ko/guides/native/) |
|
||||
| 오프라인 ELF 레이아웃 | 섹션, 세그먼트, 원본 심볼/재배치 정보, 정적 분석으로 얻은 보안 완화 기법 후보 | Linux x64에서 호출자가 제공하는 pwntools; [바이너리 진단](../binary-diagnostics.md) |
|
||||
| Go 바이너리 | 내장된 컴파일러 버전, 모듈, `replace` 지시어, 빌드 설정 | REA 내장 ELF/PE/thin Mach-O 정적 검사; Go 설치 불필요 |
|
||||
| Rust 바이너리 | cargo-auditable이 내장한 크레이트 이름, 버전, 소스, 의존성 관계 | REA 내장 ELF/PE/thin Mach-O 정적 검사; [Rust 바이너리](../rust-binaries.md) |
|
||||
| WASM 아티팩트 | 유효성 검사, 섹션, import/export의 정확한 WAT 형식, 디코딩된 WAT | 호출자 제공 WABT 1.0.42; `inspect_wasm_artifact` |
|
||||
| EVM 바이트코드 | 디스패치 선택자, 바이트 오프셋, 추론한 인자와 상태 변경 가능 여부 | Linux x64의 로컬 원시 바이트/16진수 입력 파일; [오프라인 EVM 가이드](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| SQLite 데이터베이스 | 테이블, 열, 인덱스, 뷰, 트리거, 선택한 일반 테이블의 행 | 로컬 데이터베이스/WAL 스냅샷; Node.js 24.15 이상의 24.x 또는 26 이상(네이티브 SQLite 한도 적용) |
|
||||
| 기록된 Linux 크래시 | 원시 note 레코드, 기록된 모든 스레드의 레지스터/시그널, 선택적 매핑 후보 | Linux x64에서 호출자가 제공하는 pwntools; GDB/pwndbg는 선택 사항; [기록된 크래시](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | 모듈, 임포트, 소스 맵, 라우트, IPC, 네이티브 애드온 관계 | Node.js와 npm; [애플리케이션 분석](https://rea.tools/ko/guides/javascript/) |
|
||||
| 웹사이트 | 페이지 구조, 스크립트, 네트워크 관찰 결과, 요청한 스크린샷 | Chrome 계열 브라우저; [브라우저 분석](https://rea.tools/ko/guides/browser/) |
|
||||
| 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](../web-network-captures.md) |
|
||||
| 패킷 캡처 | 엔드포인트, DNS, TLS SNI, 프로토콜 분포, HTTP 요청 | 호출자 제공 tshark 및 capinfos; [pcap 가이드](../pcap-analysis.md) |
|
||||
| .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 정적 검사; [관리 코드 가이드](../managed-code-analysis.md) |
|
||||
| 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR 또는 Fiddler Everywhere SAZ; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](../web-network-captures.md) |
|
||||
| .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 별도 도구 불필요(정적 검사만); [관리 코드 가이드](../managed-code-analysis.md) |
|
||||
| Android APK | 매니페스트 선언, 클래스, 디컴파일된 메서드, 참조 | Linux/macOS/Windows x64의 헤드리스 JADX와 전체 JDK; [Android 가이드](../android-analysis.md) |
|
||||
| Android 기기 | 기기, 패키지, 프로세스, 로그, 덤프, 화면 캡처, APK/파일 전송 | 호출자 제공 adb(에뮬레이터 또는 실제 기기); [ADB 가이드](../adb-device-analysis.md) |
|
||||
| JEB 프로젝트 | 엔진 기반 프로젝트 유닛, 아티팩트 해시, 타입/메서드 의사코드 | 호출자가 시작하고 MCP를 제공하는 JEB 클라이언트(REA_JEB_MCP_URL로 선택); [JEB 가이드](../jeb-analysis.md) |
|
||||
@@ -154,15 +158,18 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니
|
||||
| Flutter 앱 | Dart 스냅샷 해시, ABI별 라이브러리 해시, 엔진 빌드 출처 | 없음 (정적 APK 파싱); [Flutter 가이드](../flutter-analysis.md) |
|
||||
| 펌웨어 | 영역, 추출 결과, 네이티브 분석으로 전달할 정보 | Linux의 Binwalk / Unblob; [펌웨어 가이드](../firmware-analysis.md) |
|
||||
| 기록된 패킷 | 선택 필드, 반복 값, 필터 및 디코딩 규칙 | Linux: TShark 4.x + prlimit; [캡처 가이드](../packet-captures.md) |
|
||||
| 패킷 요약 | 엔드포인트, DNS, TLS SNI, 프로토콜 분포, HTTP 요청 | 호출자 제공 tshark 및 capinfos(Linux에서 검증됨); [pcap 가이드](../pcap-analysis.md) |
|
||||
| 기록된 논리 신호 | 디코더 주석, 샘플 범위, 진단 정보 | Linux: sigrok-cli 0.7.2 + prlimit; [신호 기록 가이드](../signal-recordings.md) |
|
||||
| 패키지 및 리소스 | 파일 목록, 다이제스트, plist, Apple 번들 구조, 추출한 리소스 | [아티팩트 및 JavaScript 가이드](../javascript-artifact-reconstruction.md), [Apple 애플리케이션](../apple-application-analysis.md) |
|
||||
| 프로세스 동작 | 터미널 출력, 상호작용, 종료 및 파일 시스템 관찰 결과, 실행 비교 | 네이티브 PTY를 지원하는 Linux/macOS; [프로세스 캡처](../process-capture.md) |
|
||||
|
||||
정적 JavaScript 및 .NET 검사는 제공된 파일을 읽으며 애플리케이션을 실행하지 않습니다. 런타임 캡처는 사용자의 권한으로 선택한 대상을 실행하거나 대상과 상호작용합니다. 각 런타임 가이드에서 그 영향을 설명합니다.
|
||||
|
||||
오프라인 네이티브 바이너리 검사에는 호출자가 제공하는 GNU objdump와 Rizin도 사용할 수 있습니다. 제공자 설정, CLI 명령, Evidence의 한계는 [리버스 엔지니어링 도구 가이드](../reverse-engineering-tools.md)를 참고하세요. REA는 objdump와 Rizin을 설치하거나 업데이트하지 않습니다.
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
지원하는 네이티브 형식과 호스트는 제공자마다 다릅니다. [Hopper 및 Ghidra 설정](../installation.md#hopper), [IDA 가이드](../ida-provider.md), [실험적 Windows Ghidra 지원](../windows-ghidra-p0.md)을 참고하세요. Ghidra는 [16비트 DOS 분석](../ghidra-dos.md)도 지원합니다. 제공자 선택은 [CLI 가이드](../cli.md#choose-a-provider)를 참고하세요. 최신 npm 릴리스 이후 추가된 기능은 [릴리스에서의 사용 가능 여부](../installation.md#released-package-and-main)를 확인하세요.
|
||||
지원하는 네이티브 형식과 호스트는 제공자마다 다릅니다. [Hopper 및 Ghidra 설정](../installation.md#hopper), [IDA 가이드](../ida-provider.md)(선택 사항인 네이티브 MCP 브리지 포함), [실험적 Windows Ghidra 지원](../windows-ghidra-p0.md)을 참고하세요. Ghidra는 [16비트 DOS 분석](../ghidra-dos.md)도 지원합니다. 큰 바이너리를 분석할 때는 `REA_GHIDRA_STARTUP_TIMEOUT_MS` 또는 `REA_HOPPER_STARTUP_TIMEOUT_MS`로 제공자의 시작 제한 시간을 늘리세요. 제공자 선택은 [CLI 가이드](../cli.md#choose-a-provider)를 참고하세요. 최신 npm 릴리스 이후 추가된 기능은 [릴리스에서의 사용 가능 여부](../installation.md#released-package-and-main)를 확인하세요.
|
||||
|
||||
## 사례
|
||||
|
||||
@@ -209,7 +216,7 @@ REA로 흥미로운 대상을 분석했다면 알려 주세요. [issue](https://
|
||||
<details>
|
||||
<summary><strong>Hopper를 먼저 실행해야 하나요?</strong></summary>
|
||||
|
||||
REA는 작업에 필요할 때 Hopper를 실행합니다. macOS에서는 처음 실행할 때 데모 모드를 선택하거나 라이선스를 활성화하라는 대화 상자가 나타날 수 있습니다. [Hopper 실행 및 문제 해결](../installation.md#launcher-paths-and-troubleshooting)을 참고하세요.
|
||||
REA는 작업에 필요할 때 Hopper를 실행합니다. macOS에서는 처음 실행할 때 데모 모드를 선택하거나 라이선스를 활성화하라는 대화 상자가 나타날 수 있습니다. 첫 CLI/MCP 작업이나 무인 작업을 실행하기 전에 같은 사용자 세션에서 이 대화 상자를 완료하세요. 필요하면 Hopper를 직접 여세요. [Hopper 실행 및 문제 해결](../installation.md#launcher-paths-and-troubleshooting)을 참고하세요.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -239,7 +246,7 @@ REA는 대상을 로컬에서 분석합니다. 에이전트는 도구 결과를
|
||||
|
||||
먼저 업데이트하세요. 최근 릴리스에서 이미 수정되었을 수 있습니다.
|
||||
|
||||
npm으로 설치한 CLI에서는 다음 명령을 사용합니다.
|
||||
`npm install --global` 또는 `bun add --global`로 설치한 CLI에서는 다음 명령을 사용합니다.
|
||||
|
||||
```bash
|
||||
rea update
|
||||
@@ -286,11 +293,9 @@ REA를 사용하고, 버그를 보고하고, 기능을 요청하고, 빌드를
|
||||
|
||||
책임 있는 사용에 관한 안내는 [DISCLAIMER.md](../../DISCLAIMER.md)를 참고하세요.
|
||||
|
||||
REA는 법을 준수하는 연구, 교육, 상호운용성 확보, 소프트웨어 보존, 그리고 본인이 소유하거나 조사 권한을 가진 소프트웨어의 분석을 위한 도구입니다.
|
||||
불법적이거나 허가되지 않은 사용에 대한 프로젝트의 입장은 모든 REA 버전, 배포 채널, 플랫폼에 적용되며, 각 사본에는 해당 라이선스가 그대로 유지됩니다.
|
||||
|
||||
The project's position on illegal or unauthorized use covers all REA
|
||||
versions, distribution channels, and platforms; each copy retains its
|
||||
applicable licenses.
|
||||
REA는 법을 준수하는 연구, 교육, 상호운용성 확보, 소프트웨어 보존, 그리고 본인이 소유하거나 조사 권한을 가진 소프트웨어의 분석을 위한 도구입니다.
|
||||
|
||||
## 기여
|
||||
|
||||
@@ -302,14 +307,6 @@ REA에 도움을 보태 주세요! [issue를 등록](https://github.com/morluto/
|
||||
|
||||
[MIT](../../LICENSE)
|
||||
|
||||
REA-authored code is licensed under MIT. Third-party material retains its own
|
||||
licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md)
|
||||
for the dependency inventory, preserved license texts, and external-tool terms.
|
||||
REA가 작성한 코드는 MIT 라이선스를 따릅니다. 제3자 자료에는 각자의 라이선스와 저작권 고지가 그대로 유지됩니다. 의존성 목록, 보존된 라이선스 원문, 외부 도구 약관은 [제3자 고지](../../THIRD_PARTY_NOTICES.md)를 참고하세요.
|
||||
|
||||
**You must legally obtain and be authorized to use commercial tools**, including
|
||||
IDA Pro / Hex-Rays decompilers, Hopper, and JEB, under their applicable licenses.
|
||||
REA grants no tool license, key, or activation entitlement. Trials and demos
|
||||
remain subject to vendor terms, including user, feature, automation, and output
|
||||
sharing restrictions. Cracked copies, stolen keys, unauthorized key sharing,
|
||||
and bypassing license restrictions are not authorized. This guidance does not
|
||||
amend MIT or third-party licenses.
|
||||
IDA Pro / Hex-Rays 디컴파일러, Hopper, JEB 등 상용 도구는 해당 라이선스에 따라 **합법적으로 취득하고 사용 권한을 갖춰야 합니다.** REA는 어떤 도구의 라이선스, 키, 활성화 권한도 부여하지 않습니다. 체험판과 데모는 사용자, 기능, 자동화, 출력 공유 제한을 포함한 공급업체 약관을 따릅니다. 크랙 버전, 도난 키, 무단 키 공유, 라이선스 제한 우회는 허용되지 않습니다. 이 안내는 MIT 라이선스나 제3자 라이선스를 변경하지 않습니다.
|
||||
|
||||
@@ -215,6 +215,27 @@ async function verifyNativeValueAdapters(
|
||||
const functionEvidence = parseEvidence(analyzed.structuredContent);
|
||||
assert.equal(functionEvidence.operation, "analyze_function");
|
||||
assert.equal(functionEvidence.provider.id, "ghidra");
|
||||
assert.deepEqual(
|
||||
functionEvidence.normalized_result.facet_availability,
|
||||
Object.fromEntries(
|
||||
[
|
||||
"pseudocode",
|
||||
"assembly",
|
||||
"comments",
|
||||
"callers",
|
||||
"callees",
|
||||
"references",
|
||||
"strings_names",
|
||||
"cfg",
|
||||
].map((facet) => [facet, { available: true }]),
|
||||
),
|
||||
);
|
||||
assert.equal(functionEvidence.relationship_coverage?.status, "complete");
|
||||
assert.ok(
|
||||
functionEvidence.relationship_coverage.diagnostics.some((diagnostic) =>
|
||||
diagnostic.includes("indirect calls remain unknown"),
|
||||
),
|
||||
);
|
||||
const functionProfile = analysisProfileSchema.parse(
|
||||
functionEvidence.analysis_profile,
|
||||
);
|
||||
@@ -247,6 +268,7 @@ async function verifyNativeValueAdapters(
|
||||
assert.ok(
|
||||
comparisonEvidence.evidence_links.includes(functionEvidence.evidence_id),
|
||||
);
|
||||
await verifyUnresolvedCallEvidence(client);
|
||||
const bundle = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
@@ -307,3 +329,63 @@ async function verifyNativeValueAdapters(
|
||||
unknowns: cliGraph.unknowns.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyUnresolvedCallEvidence(client) {
|
||||
const inventory = await client.callTool({
|
||||
name: "list_procedures",
|
||||
arguments: {},
|
||||
});
|
||||
assert.notEqual(inventory.isError, true, JSON.stringify(inventory));
|
||||
const procedures = parseEvidence(
|
||||
inventory.structuredContent,
|
||||
).normalized_result;
|
||||
const indirect = procedures.find((item) =>
|
||||
item.value.endsWith("rea_ghidra_inventory_indirect"),
|
||||
);
|
||||
const leaf = procedures.find((item) =>
|
||||
item.value.endsWith("rea_ghidra_inventory_leaf"),
|
||||
);
|
||||
assert.ok(
|
||||
indirect && leaf,
|
||||
"Source fixture lacks the unresolved callback and its possible target",
|
||||
);
|
||||
const analyzed = await client.callTool(
|
||||
{ name: "analyze_function", arguments: { procedure: indirect.address } },
|
||||
{ timeout: 180000 },
|
||||
);
|
||||
assert.notEqual(analyzed.isError, true, JSON.stringify(analyzed));
|
||||
const evidence = parseEvidence(analyzed.structuredContent);
|
||||
assert.equal(evidence.relationship_coverage?.status, "partial");
|
||||
assert.ok(evidence.normalized_result.unresolved_calls.length > 0);
|
||||
const traced = await client.callTool(
|
||||
{
|
||||
name: "trace_call_path",
|
||||
arguments: {
|
||||
start: indirect.address,
|
||||
goal: leaf.address,
|
||||
direction: "forward",
|
||||
},
|
||||
},
|
||||
{ timeout: 180000 },
|
||||
);
|
||||
assert.notEqual(traced.isError, true, JSON.stringify(traced));
|
||||
const trace = parseEvidence(traced.structuredContent).normalized_result;
|
||||
assert.equal(trace.goal_status, "unknown");
|
||||
assert.equal(trace.truncated, true);
|
||||
assert.ok(trace.residual_unknowns.length > 0);
|
||||
const compared = await client.callTool(
|
||||
{
|
||||
name: "compare_functions",
|
||||
arguments: {
|
||||
left: analyzed.structuredContent,
|
||||
right: analyzed.structuredContent,
|
||||
},
|
||||
},
|
||||
{ timeout: 180000 },
|
||||
);
|
||||
assert.notEqual(compared.isError, true, JSON.stringify(compared));
|
||||
assert.equal(
|
||||
parseEvidence(compared.structuredContent).normalized_result.status,
|
||||
"unknown",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export async function verifyGhidraBoundaries(
|
||||
assert.deepEqual(externalDossier.callees, []);
|
||||
assert.deepEqual(externalDossier.facet_availability.pseudocode, {
|
||||
available: false,
|
||||
reason: "Ghidra did not supply pseudocode.",
|
||||
reason: "Ghidra does not decompile external functions.",
|
||||
});
|
||||
for (const facet of [
|
||||
"assembly",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
access,
|
||||
copyFile,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rmdir,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
@@ -54,7 +55,7 @@ try {
|
||||
join(workspace, "dist", "windows", "WindowsNativeLoader.js"),
|
||||
).href;
|
||||
const source = `import assert from 'node:assert/strict';
|
||||
import {existsSync,mkdirSync,readdirSync,renameSync,statSync} from 'node:fs';
|
||||
import {existsSync,mkdirSync,readFileSync,readdirSync,renameSync,statSync,writeFileSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
import {systemWindowsNativeAuthority} from ${JSON.stringify(loader)};
|
||||
const temp=process.env.TEMP;
|
||||
@@ -64,46 +65,61 @@ assert.equal(first.retryable,true);
|
||||
assert.match(first.reason,/Delete owned object by handle failed.*Win32 5/s);
|
||||
const [name,...extra]=readdirSync(temp);assert.ok(name);assert.equal(extra.length,0);
|
||||
const path=join(temp,name);const identity=statSync(path,{bigint:true});
|
||||
// Ownership follows the retained NTFS handle even when the directory moves.
|
||||
// A replacement at its old pathname must remain caller-owned.
|
||||
const moved=path+'-renamed';renameSync(path,moved);
|
||||
assert.equal(statSync(moved,{bigint:true}).ino,identity.ino);
|
||||
// Delete sharing permits rename; ownership follows the retained NTFS handle.
|
||||
writeFileSync(join(path,'owned-child.txt'),'owned child');
|
||||
const renamed=path+'-renamed';renameSync(path,renamed);
|
||||
const moved=statSync(renamed,{bigint:true});
|
||||
assert.equal(moved.dev,identity.dev);assert.equal(moved.ino,identity.ino);
|
||||
mkdirSync(path);const replacement=statSync(path,{bigint:true});
|
||||
assert.notEqual(replacement.ino,identity.ino);
|
||||
const sentinel=join(path,'unrelated.txt');writeFileSync(sentinel,'unrelated replacement');
|
||||
if(process.argv[2]!=='teardown'){
|
||||
const second=systemWindowsNativeAuthority();assert.equal(second.available,true);
|
||||
assert.equal(existsSync(moved),false);
|
||||
assert.equal(existsSync(renamed),false);
|
||||
assert.deepEqual(readdirSync(temp),[name]);
|
||||
assert.equal(readFileSync(sentinel,'utf8'),'unrelated replacement');
|
||||
assert.equal(statSync(path,{bigint:true}).ino,replacement.ino);
|
||||
assert.equal(systemWindowsNativeAuthority(),second);
|
||||
}
|
||||
console.log(JSON.stringify({mode:process.argv[2],firstFailed:true,retainedOwner:true,inode:identity.ino.toString(),replacementName:name,replacementInode:replacement.ino.toString()}));
|
||||
console.log(JSON.stringify({mode:process.argv[2],firstFailed:true,retainedOwner:true,name,renamed,device:identity.dev.toString(),inode:identity.ino.toString(),replacementInode:replacement.ino.toString()}));
|
||||
`;
|
||||
const fixture = join(workspace, "probe.mjs");
|
||||
await writeFile(fixture, source);
|
||||
for (const mode of ["retry", "teardown"]) {
|
||||
const temp = join(workspace, mode);
|
||||
await mkdir(temp);
|
||||
const observed = JSON.parse(
|
||||
execFileSync(process.execPath, [fixture, mode], {
|
||||
encoding: "utf8",
|
||||
timeout: 20_000,
|
||||
windowsHide: true,
|
||||
env: { ...process.env, TEMP: temp, TMP: temp },
|
||||
}),
|
||||
);
|
||||
assert.equal(observed.retainedOwner, true);
|
||||
assert.deepEqual(await readdir(temp), [observed.replacementName]);
|
||||
const replacement = join(temp, observed.replacementName);
|
||||
const child = spawnSync(process.execPath, [fixture, mode], {
|
||||
encoding: "utf8",
|
||||
timeout: 20_000,
|
||||
windowsHide: true,
|
||||
env: { ...process.env, TEMP: temp, TMP: temp },
|
||||
});
|
||||
if (child.error !== undefined) throw child.error;
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
assert.equal(
|
||||
(await stat(replacement, { bigint: true })).ino.toString(),
|
||||
observed.replacementInode,
|
||||
child.stderr,
|
||||
"",
|
||||
"Native environment teardown reported a cleanup failure",
|
||||
);
|
||||
const observed = JSON.parse(child.stdout);
|
||||
assert.equal(observed.mode, mode);
|
||||
assert.equal(observed.firstFailed, true);
|
||||
assert.equal(observed.retainedOwner, true);
|
||||
await assert.rejects(access(observed.renamed), { code: "ENOENT" });
|
||||
assert.deepEqual(await readdir(temp), [observed.name]);
|
||||
const replacement = join(temp, observed.name);
|
||||
const identity = await stat(replacement, { bigint: true });
|
||||
assert.equal(identity.dev.toString(), observed.device);
|
||||
assert.equal(identity.ino.toString(), observed.replacementInode);
|
||||
assert.notEqual(identity.ino.toString(), observed.inode);
|
||||
assert.deepEqual(await readdir(replacement), ["unrelated.txt"]);
|
||||
assert.equal(
|
||||
await readFile(join(replacement, "unrelated.txt"), "utf8"),
|
||||
"unrelated replacement",
|
||||
);
|
||||
await rmdir(replacement);
|
||||
assert.deepEqual(await readdir(temp), []);
|
||||
}
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ ok: true, controlledDispositionFailure: true, retainedOwner: true, renamedOwnerCleanup: true, replacementPreserved: true, retryRecovers: true, successfulAdmissionCached: true, environmentTeardownCleansProbe: true, issue1701HostCauseReproduced: false })}\n`,
|
||||
`${JSON.stringify({ ok: true, controlledDispositionFailure: true, retainedOwner: true, renamedOwnerCleansChildren: true, unrelatedReplacementPreserved: true, retryRecovers: true, successfulAdmissionCached: true, environmentTeardownCleansProbe: true, issue1701HostCauseReproduced: false })}\n`,
|
||||
);
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
|
||||
@@ -167,7 +167,7 @@ export const parseZcodeFallbackConfiguration = (
|
||||
? {}
|
||||
: requireObject(document.mcpServers, "ZCode fallback mcpServers"),
|
||||
serversPath: ["mcpServers"],
|
||||
dialect: "json",
|
||||
dialect: "zcode_fallback",
|
||||
legacyServers: {},
|
||||
};
|
||||
};
|
||||
@@ -183,6 +183,7 @@ export const parseClientConfiguration = (
|
||||
if (format === undefined || format === "unsupported")
|
||||
throw new TypeError("client does not have a supported MCP config format");
|
||||
// DeepSeek Harness stores a Cordis patch list, not a server table.
|
||||
if (format === "zcode_fallback") return parseZcodeFallbackConfiguration(text);
|
||||
if (format === "dsh") return parseDshCordisDocument(text);
|
||||
const document = parseDocument(text, format);
|
||||
const serversKey = clientConfigurationServersKey(format);
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.j
|
||||
import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js";
|
||||
import { readReferenceSource } from "../reference/ReferenceSourceReader.js";
|
||||
import type {
|
||||
ReferenceSourceEntryKind,
|
||||
ReferenceSourceExclusionKind,
|
||||
ReferenceSourceRead,
|
||||
ReferenceSourceReaderError,
|
||||
} from "../reference/ReferenceSourceReaderTypes.js";
|
||||
@@ -111,7 +111,8 @@ const deriveInventoryState = (
|
||||
entry.limitations.length > 0 ||
|
||||
(entry.kind === "file" && entry.content_state !== "hashed") ||
|
||||
(entry.kind === "directory" && entry.tree_state !== "enumerated") ||
|
||||
(entry.kind === "symlink" && entry.target_state !== "internal"),
|
||||
(entry.kind === "symlink" && entry.target_state !== "internal") ||
|
||||
entry.kind === "unknown",
|
||||
);
|
||||
if (partialEntry) return "partial";
|
||||
const partialRelationship = input.relationships.some(
|
||||
@@ -142,11 +143,24 @@ const createShouldExclude =
|
||||
exclusions: HistoricalSourceGraphInput["exclusions"],
|
||||
secrets: PreparedReferenceSourceImport["secrets"],
|
||||
ignored: PreparedReferenceSourceImport["ignored"],
|
||||
): ((path: string, kind: ReferenceSourceEntryKind) => boolean) =>
|
||||
): ((path: string, kind: ReferenceSourceExclusionKind) => boolean) =>
|
||||
(path, kind) => {
|
||||
const patternPath = kind === "directory" ? `${path}/` : path;
|
||||
const secretMatch = secrets.test(patternPath);
|
||||
if (secretMatch.ignored) {
|
||||
// An entry of unknown kind may be a directory, so directory-only rules
|
||||
// must still exclude it; this fails closed for caller-marked secrets.
|
||||
const patternPaths =
|
||||
kind === "directory"
|
||||
? [`${path}/`]
|
||||
: kind === "unknown"
|
||||
? [path, `${path}/`]
|
||||
: [path];
|
||||
const firstMatch = (matcher: typeof secrets) => {
|
||||
const results = patternPaths.map((patternPath) =>
|
||||
matcher.test(patternPath),
|
||||
);
|
||||
return results.find(({ ignored }) => ignored) ?? results[0];
|
||||
};
|
||||
const secretMatch = firstMatch(secrets);
|
||||
if (secretMatch?.ignored === true) {
|
||||
if (!secretMatch.rule)
|
||||
throw new Error(`Ignored secret path has no matching rule: ${path}`);
|
||||
exclusions.push({
|
||||
@@ -156,8 +170,8 @@ const createShouldExclude =
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const match = ignored.test(patternPath);
|
||||
if (!match.ignored) return false;
|
||||
const match = firstMatch(ignored);
|
||||
if (match?.ignored !== true) return false;
|
||||
if (!match.rule)
|
||||
throw new Error(`Ignored path has no matching rule: ${path}`);
|
||||
const reason = match.rule.mark;
|
||||
|
||||
@@ -45,6 +45,8 @@ const entryRecoveryGuidance = (
|
||||
return "This directory could not be read. Check its permissions, then try again.";
|
||||
if (entry.kind === "symlink")
|
||||
return "This symbolic link could not be read safely. Check the link and its permissions, then try again.";
|
||||
if (entry.kind === "unknown")
|
||||
return "This entry's type could not be determined. Check the parent directory and try again.";
|
||||
return "This file could not be read. Check its permissions, then try again.";
|
||||
};
|
||||
|
||||
@@ -73,6 +75,13 @@ const failedEntry = (
|
||||
classifications,
|
||||
limitations: [limitation],
|
||||
};
|
||||
if (entry.kind === "unknown")
|
||||
return {
|
||||
path: entry.path,
|
||||
kind: "unknown",
|
||||
classifications: ["unknown"],
|
||||
limitations: [limitation],
|
||||
};
|
||||
return {
|
||||
path: entry.path,
|
||||
kind: "file",
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface SetupClient {
|
||||
| "pi"
|
||||
| "kimi"
|
||||
| "hermes"
|
||||
| "zcode_fallback"
|
||||
| "zcode"
|
||||
| "dsh"
|
||||
| "unsupported";
|
||||
@@ -606,6 +607,14 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
|
||||
markerPath: traeUserDirectory,
|
||||
format: "json",
|
||||
},
|
||||
{
|
||||
name: "workbuddy",
|
||||
displayName: "WorkBuddy",
|
||||
skillPath: [".workbuddy", "skills"],
|
||||
configPath: [".workbuddy", "mcp.json"],
|
||||
markerPath: [".workbuddy"],
|
||||
format: "json",
|
||||
},
|
||||
{
|
||||
name: "grok_build",
|
||||
displayName: "Grok Build",
|
||||
|
||||
@@ -231,12 +231,21 @@ export const systemUninstallHost = (
|
||||
const clients = supportedClients(home, platform, environment);
|
||||
redirection = appDataRedirection(clients);
|
||||
return Promise.resolve(
|
||||
clients.flatMap((client) =>
|
||||
uninstallConfigPaths(client).map((configPath) => ({
|
||||
clients.flatMap((client) => [
|
||||
...uninstallConfigPaths(client).map((configPath) => ({
|
||||
...client,
|
||||
configPath,
|
||||
})),
|
||||
),
|
||||
...(client.zcodeFallbackPath === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
...client,
|
||||
configPath: client.zcodeFallbackPath,
|
||||
format: "zcode_fallback" as const,
|
||||
},
|
||||
]),
|
||||
]),
|
||||
);
|
||||
},
|
||||
inspectClient: async (client) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { dirname } from "node:path";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { access, realpath, stat } from "node:fs/promises";
|
||||
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type {
|
||||
@@ -43,11 +44,35 @@ const resolveInputPath = async (
|
||||
}
|
||||
};
|
||||
|
||||
/** Resolve the executable and require a launchable regular file before any spawn. */
|
||||
const resolveExecutablePath = async (
|
||||
selected: string,
|
||||
): ReturnType<typeof resolveInputPath> => {
|
||||
const resolved = await resolveInputPath("executable_path", selected);
|
||||
if (!resolved.ok) return resolved;
|
||||
try {
|
||||
if (!(await stat(resolved.value)).isFile())
|
||||
throw new Error("not a regular file");
|
||||
if (process.platform !== "win32")
|
||||
await access(resolved.value, constants.X_OK);
|
||||
return resolved;
|
||||
} catch (cause: unknown) {
|
||||
return err({
|
||||
issue: {
|
||||
path: ["executable_path"],
|
||||
reason: "invalid_value",
|
||||
message: `executable_path ${selected} is not an executable file: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
},
|
||||
cause,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const canonicalizeInput = async (
|
||||
input: ElectronActiveObservationInput,
|
||||
): Promise<Result<CanonicalElectronActiveObservationInput, AnalysisError>> => {
|
||||
const [executable, application, root] = await Promise.all([
|
||||
resolveInputPath("executable_path", input.executable_path),
|
||||
resolveExecutablePath(input.executable_path),
|
||||
resolveInputPath("application_path", input.application_path),
|
||||
input.application_root === undefined
|
||||
? Promise.resolve(ok(undefined))
|
||||
|
||||
@@ -96,13 +96,16 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
|
||||
await systemProcessOwnershipHost.captureBaseline?.(options.signal);
|
||||
if (options.signal?.aborted)
|
||||
throw new BrowserObservationError(OPERATION, "cancelled");
|
||||
application = await this.launch({
|
||||
const launchOptions: Parameters<typeof electron.launch>[0] = {
|
||||
executablePath: paths.executable,
|
||||
cwd: paths.root,
|
||||
env: safeElectronEnvironment(this.environment, runId),
|
||||
args: ["-r", hookPath, paths.application, ...input.args],
|
||||
timeout: Math.max(1, startupDeadline - performance.now()),
|
||||
});
|
||||
};
|
||||
application = await containLaunchRejections(() =>
|
||||
this.launch(launchOptions),
|
||||
);
|
||||
const leaderPid = application.process().pid;
|
||||
if (
|
||||
typeof leaderPid !== "number" ||
|
||||
@@ -377,6 +380,88 @@ const safeElectronEnvironment = (
|
||||
["REA_PROCESS_RUN_ID", runId] as const,
|
||||
]);
|
||||
|
||||
const PLAYWRIGHT_INTERNAL_FRAME = /playwright-core[\\/]lib[\\/]/;
|
||||
interface LaunchRejectionScope {
|
||||
failure?: unknown;
|
||||
}
|
||||
const activeLaunches = new Set<LaunchRejectionScope>();
|
||||
const heldLaunchRejections = new Map<
|
||||
Promise<unknown>,
|
||||
{
|
||||
readonly reason: Error;
|
||||
readonly candidates: Set<LaunchRejectionScope>;
|
||||
readonly hadExternalListener: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
const holdLaunchRejection = (
|
||||
reason: unknown,
|
||||
promise: Promise<unknown>,
|
||||
): void => {
|
||||
if (
|
||||
!(reason instanceof Error) ||
|
||||
!PLAYWRIGHT_INTERNAL_FRAME.test(reason.stack ?? "")
|
||||
)
|
||||
throw reason;
|
||||
if (!heldLaunchRejections.has(promise))
|
||||
heldLaunchRejections.set(promise, {
|
||||
reason,
|
||||
candidates: new Set(activeLaunches),
|
||||
hadExternalListener: process
|
||||
.listeners("unhandledRejection")
|
||||
.some((listener) => listener !== holdLaunchRejection),
|
||||
});
|
||||
};
|
||||
|
||||
const forwardLaunchRejection = (
|
||||
reason: Error,
|
||||
hadExternalListener: boolean,
|
||||
): void => {
|
||||
// Listeners present at the original event already observed it. When the
|
||||
// holding listener was the only listener, restore Node's fatal behavior.
|
||||
if (!hadExternalListener)
|
||||
process.nextTick(() => {
|
||||
throw reason;
|
||||
});
|
||||
};
|
||||
|
||||
/** Playwright leaves rejected launch promises unhandled; absorb only those that repeat this launch's failure. */
|
||||
const containLaunchRejections = async <T>(
|
||||
launch: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const scope: LaunchRejectionScope = {};
|
||||
if (activeLaunches.size === 0)
|
||||
process.on("unhandledRejection", holdLaunchRejection);
|
||||
activeLaunches.add(scope);
|
||||
try {
|
||||
return await launch();
|
||||
} catch (cause: unknown) {
|
||||
scope.failure = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
activeLaunches.delete(scope);
|
||||
if (activeLaunches.size === 0)
|
||||
process.off("unhandledRejection", holdLaunchRejection);
|
||||
for (const [
|
||||
promise,
|
||||
{ reason, candidates, hadExternalListener },
|
||||
] of heldLaunchRejections) {
|
||||
if (!candidates.delete(scope)) continue;
|
||||
if (
|
||||
scope.failure instanceof Error &&
|
||||
reason.message !== "" &&
|
||||
scope.failure.message.includes(reason.message)
|
||||
)
|
||||
heldLaunchRejections.delete(promise);
|
||||
else if (candidates.size === 0) {
|
||||
heldLaunchRejections.delete(promise);
|
||||
forwardLaunchRejection(reason, hadExternalListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const providerError = (cause: unknown): AnalysisError =>
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
|
||||
@@ -45,6 +45,24 @@ const registerOverviewCommands = (
|
||||
.describe("Load and update a local analysis snapshot"),
|
||||
...nativeOpenOptionFields,
|
||||
});
|
||||
// `analyze` declares the JavaScript integrity policy for its JavaScript
|
||||
// lane. Preserve whether the caller supplied it until the target lane is
|
||||
// known, then apply the JavaScript default only in that lane. The owner
|
||||
// field fills an omission with that default, and an `.optional()` wrapper
|
||||
// keeps reporting it, so every binary target would look like an explicit
|
||||
// policy. Remove the owner's default before making the CLI option optional;
|
||||
// reapply its metadata so help and `--schema` keep describing the option.
|
||||
const ownerIntegrityPolicy =
|
||||
javascriptApplicationOptions.shape.integrityPolicy;
|
||||
const defaultIntegrityPolicy = ownerIntegrityPolicy.parse(undefined);
|
||||
const policyMetadata = ownerIntegrityPolicy.meta();
|
||||
const omittableIntegrityPolicy = ownerIntegrityPolicy
|
||||
.removeDefault()
|
||||
.optional();
|
||||
const laneIntegrityPolicy =
|
||||
policyMetadata === undefined
|
||||
? omittableIntegrityPolicy
|
||||
: omittableIntegrityPolicy.meta(policyMetadata);
|
||||
cli.command(CLI_COMMANDS.analyze, {
|
||||
description: "Get an overview of an app",
|
||||
args: z.object({
|
||||
@@ -54,7 +72,7 @@ const registerOverviewCommands = (
|
||||
.describe("App, program, or analysis database path"),
|
||||
}),
|
||||
options: overviewOptions.extend({
|
||||
integrityPolicy: javascriptApplicationOptions.shape.integrityPolicy,
|
||||
integrityPolicy: laneIntegrityPolicy,
|
||||
sourcePath: javascriptApplicationOptions.shape.sourcePath,
|
||||
}),
|
||||
run: async ({ args, options }) => {
|
||||
@@ -67,7 +85,8 @@ const registerOverviewCommands = (
|
||||
runCliJavaScriptApplicationAnalysis(
|
||||
{
|
||||
input_path: resolve(args.path),
|
||||
integrity_policy: options.integrityPolicy,
|
||||
integrity_policy:
|
||||
options.integrityPolicy ?? defaultIntegrityPolicy,
|
||||
...(options.sourcePath === undefined
|
||||
? {}
|
||||
: { source_paths: options.sourcePath }),
|
||||
@@ -90,6 +109,19 @@ const registerOverviewCommands = (
|
||||
},
|
||||
],
|
||||
);
|
||||
if (options.integrityPolicy !== undefined)
|
||||
throw new AnalysisInputError(
|
||||
"analyze_javascript_application",
|
||||
undefined,
|
||||
[
|
||||
{
|
||||
path: ["integrity_policy"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"An integrity policy requires the static JavaScript ASAR/directory analysis lane; binary_overview accepts no input.",
|
||||
},
|
||||
],
|
||||
);
|
||||
return runDirectAnalysis(
|
||||
args.path,
|
||||
"binary_overview",
|
||||
|
||||
@@ -337,6 +337,42 @@ describe("call path topology and capacity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("directional call relationship coverage", () => {
|
||||
it.each([
|
||||
{
|
||||
directions: ["callers"] as ["callers"],
|
||||
status: "not_found",
|
||||
exhaustive: true,
|
||||
},
|
||||
{
|
||||
directions: ["callees"] as ["callees"],
|
||||
status: "unknown",
|
||||
exhaustive: false,
|
||||
},
|
||||
{
|
||||
directions: ["callers", "callees"] as ["callers", "callees"],
|
||||
status: "unknown",
|
||||
exhaustive: false,
|
||||
},
|
||||
])(
|
||||
"uses $directions coverage for the forward search",
|
||||
({ directions, status, exhaustive }) => {
|
||||
const limitation = "The inventory omitted a reference.";
|
||||
const relationshipCoverage: RelationshipCoverage = {
|
||||
status: "partial",
|
||||
limitations: [limitation],
|
||||
limited_directions: directions,
|
||||
};
|
||||
const result = run([
|
||||
observe("0x1000", ["0x2000"], "rea-workflow", { relationshipCoverage }),
|
||||
observe("0x2000", [], "rea-workflow", { relationshipCoverage }),
|
||||
]);
|
||||
expect(result).toMatchObject({ status, search_scope: { exhaustive } });
|
||||
expect(result.limitations).toContain(limitation);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("explicit call relationship coverage", () => {
|
||||
it("accumulates 150,000 partial reasons without argument-stack exhaustion", () => {
|
||||
const result = run([
|
||||
|
||||
+8
-10
@@ -1,3 +1,4 @@
|
||||
import { relationshipCoverageLimitations } from "./relationshipCoverage.js";
|
||||
import {
|
||||
callPathInputSchema,
|
||||
callPathResultSchema,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
parseFunctionEvidence,
|
||||
type FunctionSnapshot,
|
||||
} from "./functionDossierEvidence.js";
|
||||
import { isCallerOnlyUnknownCoverage } from "./functionCallCoverage.js";
|
||||
import { isCallerOnlyIncompleteCoverage } from "./functionCallCoverage.js";
|
||||
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
|
||||
|
||||
/** Minimal directed caller-to-callee adjacency for call-path search. */
|
||||
@@ -240,15 +241,12 @@ const inspectSearch = ({
|
||||
const coverage = snapshot.evidence.relationship_coverage;
|
||||
for (const limitation of snapshot.limitations)
|
||||
limitations.push(limitation);
|
||||
if (coverage?.status === "partial")
|
||||
for (const limitation of coverage.limitations)
|
||||
if (coverage !== null) {
|
||||
for (const limitation of relationshipCoverageLimitations(coverage))
|
||||
limitations.push(limitation);
|
||||
else if (coverage?.status === "unknown")
|
||||
limitations.push(coverage.reason);
|
||||
for (const diagnostic of coverage !== null && "diagnostics" in coverage
|
||||
? coverage.diagnostics
|
||||
: [])
|
||||
limitations.push(diagnostic);
|
||||
for (const diagnostic of coverage.diagnostics ?? [])
|
||||
limitations.push(diagnostic);
|
||||
}
|
||||
// The requested goal is a terminal for this search. Its outgoing edges
|
||||
// cannot add a path to the goal, so its dossier limits are contextual
|
||||
// but do not make the searched start-to-goal closure incomplete.
|
||||
@@ -268,7 +266,7 @@ const inspectSearch = ({
|
||||
coverage === null
|
||||
? snapshot.limitations.length > 0
|
||||
: coverage.status !== "complete" &&
|
||||
!isCallerOnlyUnknownCoverage(coverage)
|
||||
!isCallerOnlyIncompleteCoverage(coverage)
|
||||
)
|
||||
incompleteExpandedCoverage = true;
|
||||
} else if (node === goal) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
combineFunctionRelationshipCoverage,
|
||||
isCallerOnlyUnknownCoverage,
|
||||
isCallerOnlyIncompleteCoverage,
|
||||
} from "./functionCallCoverage.js";
|
||||
import type { RelationshipCoverage } from "./relationshipCoverage.js";
|
||||
|
||||
@@ -22,14 +22,14 @@ const cappedCallees: RelationshipCoverage = {
|
||||
};
|
||||
const complete: RelationshipCoverage = { status: "complete" };
|
||||
|
||||
describe("isCallerOnlyUnknownCoverage", () => {
|
||||
describe("isCallerOnlyIncompleteCoverage", () => {
|
||||
it("detects unknown callers beside complete callees", () => {
|
||||
const combined = combineFunctionRelationshipCoverage(
|
||||
unknownCallers,
|
||||
complete,
|
||||
);
|
||||
expect(combined.status).toBe("unknown");
|
||||
expect(isCallerOnlyUnknownCoverage(combined)).toBe(true);
|
||||
expect(isCallerOnlyIncompleteCoverage(combined)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown callees beside complete callers", () => {
|
||||
@@ -37,7 +37,7 @@ describe("isCallerOnlyUnknownCoverage", () => {
|
||||
complete,
|
||||
unknownCallees,
|
||||
);
|
||||
expect(isCallerOnlyUnknownCoverage(combined)).toBe(false);
|
||||
expect(isCallerOnlyIncompleteCoverage(combined)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown coverage on both sides", () => {
|
||||
@@ -45,7 +45,7 @@ describe("isCallerOnlyUnknownCoverage", () => {
|
||||
unknownCallers,
|
||||
unknownCallees,
|
||||
);
|
||||
expect(isCallerOnlyUnknownCoverage(combined)).toBe(false);
|
||||
expect(isCallerOnlyIncompleteCoverage(combined)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown callers beside IDA-capped callees", () => {
|
||||
@@ -54,11 +54,37 @@ describe("isCallerOnlyUnknownCoverage", () => {
|
||||
cappedCallees,
|
||||
);
|
||||
expect(combined.status).toBe("unknown");
|
||||
expect(isCallerOnlyUnknownCoverage(combined)).toBe(false);
|
||||
expect(isCallerOnlyIncompleteCoverage(combined)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects complete coverage", () => {
|
||||
const combined = combineFunctionRelationshipCoverage(complete, complete);
|
||||
expect(isCallerOnlyUnknownCoverage(combined)).toBe(false);
|
||||
expect(isCallerOnlyIncompleteCoverage(combined)).toBe(false);
|
||||
});
|
||||
|
||||
it("retains caller-only partial coverage without relying on limitation wording", () => {
|
||||
const combined = combineFunctionRelationshipCoverage(
|
||||
{ status: "partial", limitations: ["Missing incoming references."] },
|
||||
complete,
|
||||
);
|
||||
expect(combined).toMatchObject({ limited_directions: ["callers"] });
|
||||
expect(isCallerOnlyIncompleteCoverage(combined)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses explicit directions even when explanation text contains direction labels", () => {
|
||||
expect(
|
||||
isCallerOnlyIncompleteCoverage({
|
||||
status: "unknown",
|
||||
reason: "Callers: unavailable. Callees: diagnostic text.",
|
||||
limited_directions: ["callers"],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCallerOnlyIncompleteCoverage({
|
||||
status: "unknown",
|
||||
reason: "Callers: unavailable.",
|
||||
limited_directions: ["callees"],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { RelationshipCoverage } from "./relationshipCoverage.js";
|
||||
import {
|
||||
relationshipCoverageLimitations,
|
||||
type RelationshipCoverage,
|
||||
} from "./relationshipCoverage.js";
|
||||
import type { FunctionDossier } from "./hopperValues.js";
|
||||
import { uniqueSorted } from "./canonicalOrdering.js";
|
||||
|
||||
@@ -15,25 +18,25 @@ export const combineFunctionRelationshipCoverage = (
|
||||
{ direction: "callees" as const, coverage: callees },
|
||||
];
|
||||
const diagnostics = uniqueSorted(
|
||||
reads.flatMap(({ coverage }) =>
|
||||
coverage !== undefined && "diagnostics" in coverage
|
||||
? coverage.diagnostics
|
||||
: [],
|
||||
),
|
||||
reads.flatMap(({ coverage }) => coverage?.diagnostics ?? []),
|
||||
);
|
||||
const details = diagnostics.length > 0 ? { diagnostics } : {};
|
||||
const callersLimited = callers?.status !== "complete";
|
||||
const calleesLimited = callees?.status !== "complete";
|
||||
const limited_directions: Extract<
|
||||
RelationshipCoverage,
|
||||
{ status: "partial" }
|
||||
>["limited_directions"] = callersLimited
|
||||
? calleesLimited
|
||||
? ["callers", "callees"]
|
||||
: ["callers"]
|
||||
: ["callees"];
|
||||
const reasons = reads.flatMap(({ direction, coverage }) => {
|
||||
const label =
|
||||
direction === "callers" ? callerCoveragePrefix : calleeCoveragePrefix;
|
||||
const limitations =
|
||||
coverage === undefined
|
||||
? ["Provider did not report relationship coverage."]
|
||||
: coverage.status === "unknown"
|
||||
? [coverage.reason]
|
||||
: coverage.status === "partial"
|
||||
? coverage.limitations
|
||||
: [];
|
||||
return limitations.map((reason) => `${label}${reason}`);
|
||||
return relationshipCoverageLimitations(coverage).map(
|
||||
(reason) => `${label}${reason}`,
|
||||
);
|
||||
});
|
||||
if (
|
||||
reads.some(
|
||||
@@ -43,54 +46,48 @@ export const combineFunctionRelationshipCoverage = (
|
||||
return {
|
||||
status: "unknown",
|
||||
reason: uniqueSorted(reasons).join(" "),
|
||||
limited_directions,
|
||||
...details,
|
||||
};
|
||||
if (reasons.length > 0)
|
||||
return {
|
||||
status: "partial",
|
||||
limitations: uniqueSorted(reasons),
|
||||
limited_directions,
|
||||
...details,
|
||||
};
|
||||
return { status: "complete", ...details };
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether combined relationship coverage is unknown only on the caller side.
|
||||
* The combine helper above prefixes each direction's reasons, so a reason
|
||||
* that carries the caller prefix and no callee prefix records observed
|
||||
* callees with unobservable callers. Forward (callee-edge) searches stay
|
||||
* exhaustive under that caveat; the caller reason remains a limitation.
|
||||
* Forward searches depend on callee coverage. Preserve the historical
|
||||
* unknown-reason encoding when reading observations without explicit directions.
|
||||
*/
|
||||
export const isCallerOnlyUnknownCoverage = (
|
||||
export const isCallerOnlyIncompleteCoverage = (
|
||||
coverage: RelationshipCoverage,
|
||||
): boolean =>
|
||||
coverage.status === "unknown" &&
|
||||
coverage.reason.startsWith(callerCoveragePrefix) &&
|
||||
!coverage.reason.includes(calleeCoveragePrefix);
|
||||
): boolean => {
|
||||
if (coverage.status === "complete") return false;
|
||||
if (coverage.limited_directions !== undefined)
|
||||
return !coverage.limited_directions.some(
|
||||
(direction) => direction === "callees",
|
||||
);
|
||||
return (
|
||||
coverage.status === "unknown" &&
|
||||
coverage.reason.startsWith(callerCoveragePrefix) &&
|
||||
!coverage.reason.includes(calleeCoveragePrefix)
|
||||
);
|
||||
};
|
||||
|
||||
/** Explain why two resolved call sets cannot establish call-set equality. */
|
||||
export const functionCallCoverageLimitations = (
|
||||
coverage: RelationshipCoverage | null,
|
||||
dossier: Pick<FunctionDossier, "unresolved_calls">,
|
||||
): string[] => {
|
||||
const limitations =
|
||||
coverage === null
|
||||
? ["Provider did not report relationship coverage."]
|
||||
: coverage.status === "unknown"
|
||||
? [
|
||||
coverage.reason,
|
||||
...("diagnostics" in coverage ? coverage.diagnostics : []),
|
||||
]
|
||||
: coverage.status === "partial"
|
||||
? [
|
||||
...coverage.limitations,
|
||||
...("diagnostics" in coverage ? coverage.diagnostics : []),
|
||||
]
|
||||
: [];
|
||||
limitations.push(
|
||||
return uniqueSorted([
|
||||
...relationshipCoverageLimitations(coverage),
|
||||
...(coverage?.status === "complete" ? [] : (coverage?.diagnostics ?? [])),
|
||||
...(dossier.unresolved_calls ?? []).map(
|
||||
({ address, reason }) => `Unresolved call at ${address}: ${reason}`,
|
||||
),
|
||||
);
|
||||
return uniqueSorted(limitations);
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
|
||||
const endpoints = (source: string) =>
|
||||
analyzeJavaScriptStaticSource(source).endpoints.map(
|
||||
({ kind, value, mechanism }) => ({ kind, value, mechanism }),
|
||||
);
|
||||
|
||||
describe("server-sent event endpoints", () => {
|
||||
it("reports EventSource URLs like WebSocket URLs", () => {
|
||||
expect(
|
||||
endpoints(`
|
||||
new WebSocket("wss://chat.example/ws");
|
||||
new EventSource("/api/chat/stream");
|
||||
new window.EventSource("https://chat.example/events", { withCredentials: true });
|
||||
`),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "network",
|
||||
value: "wss://chat.example/ws",
|
||||
mechanism: "call:WebSocket",
|
||||
},
|
||||
{
|
||||
kind: "network",
|
||||
value: "/api/chat/stream",
|
||||
mechanism: "call:EventSource",
|
||||
},
|
||||
{
|
||||
kind: "network",
|
||||
value: "https://chat.example/events",
|
||||
mechanism: "call:window.EventSource",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a dynamic EventSource URL unreported", () => {
|
||||
expect(endpoints("new EventSource(url);")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
|
||||
const endpoints = (source: string) =>
|
||||
analyzeJavaScriptStaticSource(source).endpoints.map(
|
||||
({ kind, value, mechanism }) => ({ kind, value, mechanism }),
|
||||
);
|
||||
|
||||
describe("beacon endpoints", () => {
|
||||
it("reports navigator.sendBeacon URLs", () => {
|
||||
expect(
|
||||
endpoints(`
|
||||
navigator.sendBeacon("/api/telemetry", payload);
|
||||
window.navigator.sendBeacon("https://metrics.example/collect");
|
||||
`),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "network",
|
||||
value: "/api/telemetry",
|
||||
mechanism: "call:navigator.sendBeacon",
|
||||
},
|
||||
{
|
||||
kind: "network",
|
||||
value: "https://metrics.example/collect",
|
||||
mechanism: "call:window.navigator.sendBeacon",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a dynamic beacon URL unreported", () => {
|
||||
expect(endpoints("navigator.sendBeacon(url, payload);")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -554,7 +554,13 @@ export const endpointArgument = (
|
||||
receiverFact?: JavaScriptOpenReceiverFact,
|
||||
): string | undefined => {
|
||||
if (receiverFact === "window") return undefined;
|
||||
if (name === "fetch" || name.endsWith(".fetch") || name === "WebSocket")
|
||||
if (
|
||||
name === "fetch" ||
|
||||
name.endsWith(".fetch") ||
|
||||
name === "WebSocket" ||
|
||||
name === "EventSource" ||
|
||||
name.endsWith(".EventSource")
|
||||
)
|
||||
return stringValue(args[0]);
|
||||
if (name.endsWith(".open"))
|
||||
return xhrOpenUrl(name, args[0], args[1], receiverFact);
|
||||
@@ -574,6 +580,9 @@ export const endpointArgument = (
|
||||
? undefined
|
||||
: value;
|
||||
}
|
||||
// navigator.sendBeacon(url, data) queues a POST to its first argument.
|
||||
if (name === "sendBeacon" || name.endsWith(".sendBeacon"))
|
||||
return stringValue(args[0]);
|
||||
if (name.endsWith("loadURL")) return stringValue(args[0]);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -259,6 +259,30 @@ describe("historical source graph", () => {
|
||||
});
|
||||
|
||||
describe("partial historical source graphs", () => {
|
||||
it("keeps unknown-entry diagnostics outside the root commitment", () => {
|
||||
const base = graphInput();
|
||||
base.inventory_state = "partial";
|
||||
base.entries[2] = {
|
||||
path: "src/package.json",
|
||||
kind: "unknown",
|
||||
classifications: ["unknown"],
|
||||
limitations: ["Permission denied at /private/first"],
|
||||
};
|
||||
const graph = createHistoricalSourceGraph(base);
|
||||
const changedMessage = structuredClone(base);
|
||||
changedMessage.entries[2] = {
|
||||
path: "src/package.json",
|
||||
kind: "unknown",
|
||||
classifications: ["unknown"],
|
||||
limitations: ["Permission denied at /private/second"],
|
||||
};
|
||||
const revised = createHistoricalSourceGraph(changedMessage);
|
||||
expect(revised.root_sha256).toBe(graph.root_sha256);
|
||||
expect(computeHistoricalSourceGraphSha256(revised)).not.toBe(
|
||||
computeHistoricalSourceGraphSha256(graph),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an unreadable symlink target unknown and out of root identity", () => {
|
||||
const base = graphInput();
|
||||
base.inventory_state = "partial";
|
||||
|
||||
@@ -111,6 +111,10 @@ const sourceEntrySchema = z.union([
|
||||
sourceFileSchema,
|
||||
sourceDirectorySchema,
|
||||
sourceSymlinkSchema,
|
||||
z.strictObject({
|
||||
...entryBaseShape,
|
||||
kind: z.literal("unknown"),
|
||||
}),
|
||||
]);
|
||||
|
||||
const sourceRelationshipSchema = z.strictObject({
|
||||
@@ -361,7 +365,8 @@ const checkCompleteInventory = (
|
||||
entry.limitations.length > 0 ||
|
||||
(entry.kind === "file" && entry.content_state !== "hashed") ||
|
||||
(entry.kind === "directory" && entry.tree_state !== "enumerated") ||
|
||||
(entry.kind === "symlink" && entry.target_state !== "internal"),
|
||||
(entry.kind === "symlink" && entry.target_state !== "internal") ||
|
||||
entry.kind === "unknown",
|
||||
);
|
||||
const partialRelationship = graph.relationships.some(
|
||||
({ parse_state, resolution }) =>
|
||||
@@ -478,6 +483,11 @@ const rootCommitment = (
|
||||
target: entry.target,
|
||||
target_state: entry.target_state,
|
||||
};
|
||||
case "unknown":
|
||||
return {
|
||||
kind: entry.kind,
|
||||
path: entry.path,
|
||||
};
|
||||
}
|
||||
}),
|
||||
exclusions,
|
||||
|
||||
@@ -81,6 +81,26 @@ describe("committed historical-source summaries", () => {
|
||||
expect(graph).toEqual(original);
|
||||
});
|
||||
|
||||
it("counts entries of unknown kind separately from symlinks", () => {
|
||||
const graph = createHistoricalSourceGraph(
|
||||
input([
|
||||
{
|
||||
path: "deep",
|
||||
kind: "unknown",
|
||||
classifications: ["unknown"],
|
||||
limitations: ["lstat: ENAMETOOLONG"],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(createHistoricalSourceSummary(graph, 1).counts).toMatchObject({
|
||||
entries: 1,
|
||||
files: 0,
|
||||
directories: 0,
|
||||
symlinks: 0,
|
||||
unknown: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("sums known bytes losslessly beyond the JSON safe integer range", () => {
|
||||
const graph = createHistoricalSourceGraph(
|
||||
input(
|
||||
|
||||
@@ -29,6 +29,7 @@ export const createHistoricalSourceSummary = (
|
||||
files: 0,
|
||||
directories: 0,
|
||||
symlinks: 0,
|
||||
unknown: 0,
|
||||
relationships: graph.relationships.length,
|
||||
parse_failures: graph.parse_failures.length,
|
||||
exclusions: graph.exclusions.length,
|
||||
@@ -59,7 +60,8 @@ export const createHistoricalSourceSummary = (
|
||||
} else if (entry.kind === "directory") {
|
||||
counts.directories += 1;
|
||||
treeStates[entry.tree_state] += 1;
|
||||
} else counts.symlinks += 1;
|
||||
} else if (entry.kind === "symlink") counts.symlinks += 1;
|
||||
else counts.unknown += 1;
|
||||
// Canonical entries already use Unicode code-point order; filtering preserves it.
|
||||
if (entry.path.split("/").length <= outlineDepth)
|
||||
outline.push(outlineEntry(entry));
|
||||
|
||||
@@ -1,24 +1,51 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const diagnostics = z.array(z.string().min(1)).min(1);
|
||||
const completeCoverage = z.strictObject({ status: z.literal("complete") });
|
||||
const diagnostics = z.array(z.string().min(1)).min(1).exactOptional();
|
||||
const completeCoverage = z.strictObject({
|
||||
status: z.literal("complete"),
|
||||
diagnostics,
|
||||
});
|
||||
const limitedDirections = z.union([
|
||||
z.tuple([z.literal("callers")]).check(z.minLength(1), z.maxLength(1)),
|
||||
z.tuple([z.literal("callees")]).check(z.minLength(1), z.maxLength(1)),
|
||||
z
|
||||
.tuple([z.literal("callers"), z.literal("callees")])
|
||||
.check(z.minLength(2), z.maxLength(2)),
|
||||
]);
|
||||
const partialCoverage = z.strictObject({
|
||||
status: z.literal("partial"),
|
||||
limitations: z.array(z.string().min(1)).min(1),
|
||||
limited_directions: limitedDirections.exactOptional(),
|
||||
diagnostics,
|
||||
});
|
||||
const unknownCoverage = z.strictObject({
|
||||
status: z.literal("unknown"),
|
||||
reason: z.string().min(1),
|
||||
limited_directions: limitedDirections.exactOptional(),
|
||||
diagnostics,
|
||||
});
|
||||
|
||||
/** Explicit coverage state for a provider's observed caller/callee set. */
|
||||
export const relationshipCoverageSchema = z.union([
|
||||
export const relationshipCoverageSchema = z.discriminatedUnion("status", [
|
||||
completeCoverage,
|
||||
completeCoverage.extend({ diagnostics }),
|
||||
partialCoverage,
|
||||
partialCoverage.extend({ diagnostics }),
|
||||
unknownCoverage,
|
||||
unknownCoverage.extend({ diagnostics }),
|
||||
]);
|
||||
|
||||
export type RelationshipCoverage = z.infer<typeof relationshipCoverageSchema>;
|
||||
|
||||
/** Reasons the observed relationship inventory cannot establish completeness. */
|
||||
export const relationshipCoverageLimitations = (
|
||||
coverage: RelationshipCoverage | null | undefined,
|
||||
): readonly string[] => {
|
||||
switch (coverage?.status) {
|
||||
case undefined:
|
||||
return ["Provider did not report relationship coverage."];
|
||||
case "complete":
|
||||
return [];
|
||||
case "partial":
|
||||
return coverage.limitations;
|
||||
case "unknown":
|
||||
return [coverage.reason];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,5 +5,5 @@ export const PACKAGE_METADATA = {
|
||||
serverSdkVersion: "2.3.1",
|
||||
clientSdkVersion: "2.3.1",
|
||||
coreSdkVersion: "2.3.1",
|
||||
skillVersion: "41",
|
||||
skillVersion: "42",
|
||||
} as const;
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
NATIVE_AOT_PROFILE_CONTRACT_REVISION,
|
||||
} from "../domain/native/nativeAotPe.js";
|
||||
|
||||
const callRelationshipCoverage = "function-body-call-coverage-v5";
|
||||
|
||||
/**
|
||||
* The processor language REA pins for this target, or undefined when Ghidra may
|
||||
* select from the header. The committed profile and the session readback both
|
||||
@@ -125,7 +127,7 @@ export const resolveGhidraAnalysisProfile = async (
|
||||
load_image_observations: "source-mappings-entry-context-v2",
|
||||
function_body_evidence: "complete-inclusive-ranges-v1",
|
||||
function_references: "complete-body-and-entry-reference-manager-v2",
|
||||
call_relationship_coverage: "resolved-call-reference-diagnostics-v1",
|
||||
call_relationship_coverage: callRelationshipCoverage,
|
||||
function_dossier_coverage:
|
||||
"observed-facets-and-call-relationships-v1",
|
||||
string_inventory_evidence: "defined-data-coverage-v1",
|
||||
@@ -222,7 +224,7 @@ export const resolveGhidraAnalysisProfile = async (
|
||||
load_image_observations: "source-mappings-entry-context-v2",
|
||||
function_body_evidence: "complete-inclusive-ranges-v1",
|
||||
function_references: "complete-body-and-entry-reference-manager-v2",
|
||||
call_relationship_coverage: "resolved-call-reference-diagnostics-v1",
|
||||
call_relationship_coverage: callRelationshipCoverage,
|
||||
function_dossier_coverage: "observed-facets-and-call-relationships-v1",
|
||||
string_inventory_evidence: "defined-data-coverage-v1",
|
||||
location_resolution: "explicit-address-exact-entry-symbol-first-v3",
|
||||
|
||||
@@ -15,10 +15,13 @@ import { projectGhidraDoctorInspection } from "./GhidraDoctor.js";
|
||||
const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
|
||||
const installationHost = (
|
||||
overrides: Partial<GhidraInstallationHost> = {},
|
||||
platform: NodeJS.Platform = "linux",
|
||||
): GhidraInstallationHost => ({
|
||||
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: platform === "win32" ? "Windows 11" : "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
@@ -45,7 +48,7 @@ const inspection = (
|
||||
? {}
|
||||
: { installDir: options.installDir }),
|
||||
},
|
||||
installationHost(overrides),
|
||||
installationHost(overrides, options.platform ?? "linux"),
|
||||
);
|
||||
|
||||
const doctorHost = (
|
||||
@@ -129,6 +132,8 @@ describe("Ghidra doctor integration", () => {
|
||||
{ installDir: INSTALL },
|
||||
{
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "17.0.12",
|
||||
major: 17,
|
||||
home: "/usr/lib/jvm/jdk-17",
|
||||
|
||||
@@ -43,7 +43,120 @@ const parseFunctionValueFixture = (
|
||||
return parsed.ok ? { ok: true as const, value: parsed.value.value } : parsed;
|
||||
};
|
||||
|
||||
describe("Ghidra unresolved call locations", () => {
|
||||
it("rejects a noncanonical unresolved call site rather than retaining unverifiable location evidence", () => {
|
||||
expect(
|
||||
parseGhidraFunctionResult("analyze_function", {
|
||||
value: {
|
||||
...functionDossierSchema.parse(ghidraFunctionDossier()),
|
||||
unresolved_calls: [
|
||||
{ address: "not-an-address", reason: "No call destination." },
|
||||
],
|
||||
},
|
||||
limitations: [],
|
||||
relationship_coverage: {
|
||||
status: "partial",
|
||||
limitations: ["An unresolved call remains."],
|
||||
},
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra function-analysis result values", () => {
|
||||
it("retains unavailable pseudocode instead of treating its empty representation as observed source", () => {
|
||||
const availability = {
|
||||
pseudocode: {
|
||||
available: false,
|
||||
reason: "Ghidra does not decompile external functions.",
|
||||
},
|
||||
comments: { available: true },
|
||||
};
|
||||
const parsed = parseGhidraFunctionResult("analyze_function", {
|
||||
value: {
|
||||
...functionDossierSchema.parse(ghidraFunctionDossier()),
|
||||
pseudocode: "",
|
||||
facet_availability: availability,
|
||||
},
|
||||
limitations: [],
|
||||
});
|
||||
expect(parsed).toMatchObject({
|
||||
ok: true,
|
||||
value: { value: { pseudocode: "", facet_availability: availability } },
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
{ status: "complete", diagnostics: ["Resolved static references only."] },
|
||||
{
|
||||
status: "partial",
|
||||
limitations: ["Only the first instruction range was examined."],
|
||||
},
|
||||
{ status: "unknown", reason: "Relationship inventory was unavailable." },
|
||||
{
|
||||
status: "partial",
|
||||
limitations: ["Missing callers."],
|
||||
limited_directions: ["callers"],
|
||||
},
|
||||
{
|
||||
status: "unknown",
|
||||
reason: "Missing callees.",
|
||||
limited_directions: ["callees"],
|
||||
},
|
||||
])("preserves observed $status relationship coverage", (coverage) => {
|
||||
const parsed = parseGhidraFunctionResult("analyze_function", {
|
||||
value: ghidraFunctionDossier(),
|
||||
limitations: [],
|
||||
relationship_coverage: coverage,
|
||||
});
|
||||
expect(parsed).toMatchObject({
|
||||
ok: true,
|
||||
value: { relationshipCoverage: coverage },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves omitted relationship coverage for legacy observations", () => {
|
||||
const parsed = parseGhidraFunctionResult("analyze_function", {
|
||||
value: ghidraFunctionDossier(),
|
||||
limitations: [],
|
||||
});
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
expect(parsed.value).not.toHaveProperty("relationshipCoverage");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra relationship metadata validation", () => {
|
||||
it.each([
|
||||
{ status: "partial", limitations: [] },
|
||||
{ status: "unknown", reason: "" },
|
||||
{ status: "complete", limitations: ["Truncated."] },
|
||||
{ status: "partial", limitations: ["Truncated."], limited_directions: [] },
|
||||
{
|
||||
status: "partial",
|
||||
limitations: ["Truncated."],
|
||||
limited_directions: ["callers", "callers"],
|
||||
},
|
||||
{ status: "unknown", reason: "Missing.", limited_directions: ["incoming"] },
|
||||
{ status: "complete", limited_directions: ["callers"] },
|
||||
])("rejects malformed relationship coverage %j", (coverage) => {
|
||||
expect(
|
||||
parseGhidraFunctionResult("analyze_function", {
|
||||
value: ghidraFunctionDossier(),
|
||||
limitations: [],
|
||||
relationship_coverage: coverage,
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
|
||||
});
|
||||
|
||||
it("rejects relationship metadata on unrelated function operations", () => {
|
||||
expect(
|
||||
parseGhidraFunctionResult("procedure_assembly", {
|
||||
value: "ret",
|
||||
limitations: [],
|
||||
relationship_coverage: { status: "complete" },
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
|
||||
});
|
||||
|
||||
it("preserves file-offset resolution in Evidence locations and rejects inexact offsets", () => {
|
||||
const selection = {
|
||||
file_offset: 0x210,
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
import { nativeApiBoundarySchema } from "../domain/native/nativeApiBoundary.js";
|
||||
import { nativeValueFlowSchema } from "../domain/native/nativeValueFlow.js";
|
||||
import type { EvidenceLocation } from "../domain/evidence.js";
|
||||
import {
|
||||
relationshipCoverageSchema,
|
||||
type RelationshipCoverage,
|
||||
} from "../domain/relationshipCoverage.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||
import {
|
||||
@@ -307,6 +311,7 @@ const ghidraFunctionDossier = functionDossierSchema
|
||||
...value.comments.map(({ address }) => address),
|
||||
...value.callers.map(({ address }) => address),
|
||||
...value.callees.map(({ address }) => address),
|
||||
...(value.unresolved_calls ?? []).map(({ address }) => address),
|
||||
...value.incoming_references.flatMap(referenceAddresses),
|
||||
...value.outgoing_references.flatMap(referenceAddresses),
|
||||
...value.referenced_strings.flatMap(({ address, source_address }) => [
|
||||
@@ -419,6 +424,7 @@ const functionBoundaryObservationOperations: ReadonlySet<GhidraFunctionOperation
|
||||
const functionObservationEnvelope = z.strictObject({
|
||||
value: z.unknown(),
|
||||
limitations: z.array(z.string()),
|
||||
relationship_coverage: relationshipCoverageSchema.exactOptional(),
|
||||
selection: z
|
||||
.strictObject({
|
||||
file_offset: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
|
||||
@@ -432,6 +438,7 @@ export interface ParsedGhidraFunctionResult {
|
||||
readonly value: JsonValue;
|
||||
readonly limitations: readonly string[];
|
||||
readonly locations?: readonly EvidenceLocation[];
|
||||
readonly relationshipCoverage?: RelationshipCoverage;
|
||||
}
|
||||
|
||||
/** Require exact, bounded Java-bridge function output before creating Evidence. */
|
||||
@@ -442,6 +449,7 @@ export const parseGhidraFunctionResult = (
|
||||
let observation: {
|
||||
readonly value: unknown;
|
||||
readonly limitations: readonly string[];
|
||||
readonly relationship_coverage?: RelationshipCoverage;
|
||||
readonly selection?: z.infer<
|
||||
typeof functionObservationEnvelope
|
||||
>["selection"];
|
||||
@@ -450,13 +458,17 @@ export const parseGhidraFunctionResult = (
|
||||
const envelope = functionObservationEnvelope.safeParse(value);
|
||||
if (
|
||||
!envelope.success ||
|
||||
(operation !== "analyze_function" &&
|
||||
operation !== "procedure_callers" &&
|
||||
operation !== "procedure_callees" &&
|
||||
envelope.data.relationship_coverage !== undefined) ||
|
||||
(operation !== "procedure_pseudo_code" &&
|
||||
envelope.data.selection !== undefined)
|
||||
)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Ghidra bridge omitted the function observation envelope",
|
||||
"Ghidra bridge returned an invalid function observation envelope for this operation",
|
||||
envelope.success ? undefined : { cause: envelope.error },
|
||||
),
|
||||
);
|
||||
@@ -469,6 +481,9 @@ export const parseGhidraFunctionResult = (
|
||||
? ok({
|
||||
value: jsonValueSchema.parse(parsed.data),
|
||||
limitations: observation.limitations,
|
||||
...(observation.relationship_coverage === undefined
|
||||
? {}
|
||||
: { relationshipCoverage: observation.relationship_coverage }),
|
||||
...(observation.selection === undefined
|
||||
? {}
|
||||
: {
|
||||
|
||||
@@ -12,5 +12,7 @@ export const ghidraProfileInstallationHost: GhidraInstallationHost = {
|
||||
home: "/jdk-21",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import { elf, fat, thinMach } from "../domain/binaryTarget.fixture.js";
|
||||
import { projectGhidraDoctorInspection } from "./GhidraDoctor.js";
|
||||
import {
|
||||
ghidraInstallationDiagnostics,
|
||||
ghidraJavaEnvironment,
|
||||
inspectGhidraInstallation,
|
||||
type GhidraExecutableHeaderProbe,
|
||||
@@ -16,6 +18,14 @@ import {
|
||||
ghidraJavaRequirement,
|
||||
} from "./GhidraInstallationPolicy.js";
|
||||
|
||||
// The real Java probe shells out via spawnSync; stub it so one test can run
|
||||
// the real probeJava against captured `-XshowSettings:properties` output.
|
||||
// Every other test overrides probeJava, so the stub never fires for them.
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("node:child_process")>();
|
||||
return { ...original, spawnSync: vi.fn() };
|
||||
});
|
||||
|
||||
const INSTALL = "/opt/ghidra";
|
||||
const PROPERTIES = `${INSTALL}/Ghidra/application.properties`;
|
||||
const HEADLESS = `${INSTALL}/support/analyzeHeadless`;
|
||||
@@ -32,6 +42,8 @@ const headerProbe = (bytes: Buffer): GhidraExecutableHeaderProbe => ({
|
||||
});
|
||||
|
||||
const JAVA: GhidraJavaObservation = {
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "21.0.11",
|
||||
major: SUPPORTED_GHIDRA_JAVA_MAJOR,
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
@@ -174,6 +186,11 @@ describe("Ghidra installation inspection", () => {
|
||||
{ environment: {}, installDir: INSTALL, platform, architecture },
|
||||
host({
|
||||
executable: (path) => path === HEADLESS || path === decompiler,
|
||||
probeJava: () => ({
|
||||
...JAVA,
|
||||
osArch: architecture === "arm64" ? "aarch64" : "amd64",
|
||||
osName: platform === "darwin" ? "Mac OS X" : "Linux",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
@@ -202,6 +219,7 @@ describe("Ghidra installation inspection", () => {
|
||||
return {
|
||||
...JAVA,
|
||||
home: "C:\\Java\\jdk-21",
|
||||
osName: "Windows 11",
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -721,7 +739,10 @@ describe("Ghidra FAT native decompiler admission", () => {
|
||||
platform: "darwin",
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({ executableHeader: () => headerProbe(bytes) }),
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
|
||||
executableHeader: () => headerProbe(bytes),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe(status);
|
||||
expect(
|
||||
@@ -739,7 +760,10 @@ describe("Ghidra FAT native decompiler admission", () => {
|
||||
platform: "darwin",
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({ executableHeader: () => headerProbe(bytes) }),
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
|
||||
executableHeader: () => headerProbe(bytes),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("available");
|
||||
expect(
|
||||
@@ -788,7 +812,14 @@ describe("Ghidra native decompiler admission", () => {
|
||||
])("distinguishes $name", ({ options, executableHeader }) => {
|
||||
const result = inspectGhidraInstallation(
|
||||
options,
|
||||
host({ executableHeader }),
|
||||
host({
|
||||
probeJava: () => ({
|
||||
...JAVA,
|
||||
osArch: options.architecture === "arm64" ? "aarch64" : "amd64",
|
||||
osName: options.platform === "darwin" ? "Mac OS X" : "Linux",
|
||||
}),
|
||||
executableHeader,
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
expect(
|
||||
@@ -849,6 +880,7 @@ describe("Ghidra native decompiler admission", () => {
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
|
||||
executableHeader: (path: string) =>
|
||||
path === MAC_DECOMPILER ? headerProbe(bytes) : undefined,
|
||||
}),
|
||||
@@ -872,6 +904,7 @@ describe("Ghidra native decompiler admission", () => {
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
|
||||
executableHeader: (path: string) =>
|
||||
path === MAC_DECOMPILER ? headerProbe(elf(2, 1, 62)) : undefined,
|
||||
}),
|
||||
@@ -889,3 +922,205 @@ describe("Ghidra native decompiler admission", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra JVM native directory admission", () => {
|
||||
it("rejects a JDK whose os.arch selects a different native decompiler directory", () => {
|
||||
const result = inspectGhidraInstallation(
|
||||
{
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osArch: "x86_64", osName: "Mac OS X" }),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
if (result.status !== "unavailable") return;
|
||||
expect(
|
||||
result.checks.find(({ name }) => name === "native_decompiler"),
|
||||
).toMatchObject({
|
||||
status: "failed",
|
||||
code: "executable_missing",
|
||||
detail: expect.stringContaining(
|
||||
"JVM reports os.arch=x86_64, so Ghidra selects its native decompiler from os/mac_x86_64",
|
||||
),
|
||||
remediation: expect.stringContaining("JAVA_HOME"),
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a JDK whose os.arch selects the validated native decompiler directory", () => {
|
||||
const result = inspectGhidraInstallation(
|
||||
{
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("available");
|
||||
});
|
||||
|
||||
it("rejects a successful Java probe that omits os.arch", () => {
|
||||
const { osArch: _observedArchitecture, ...withoutArchitecture } = JAVA;
|
||||
const result = inspectGhidraInstallation(
|
||||
{
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({ probeJava: () => withoutArchitecture }),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
expect(
|
||||
result.checks.find(({ name }) => name === "native_decompiler"),
|
||||
).toMatchObject({
|
||||
status: "failed",
|
||||
detail: expect.stringContaining("did not report os.arch"),
|
||||
});
|
||||
expect(projectGhidraDoctorInspection(result).available).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["riscv64", "sparcv9", ""])(
|
||||
"refuses an unrecognized JVM os.arch (%s) instead of assuming alignment",
|
||||
(osArch) => {
|
||||
const result = inspectGhidraInstallation(
|
||||
{
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osArch }),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
if (result.status !== "unavailable") return;
|
||||
expect(
|
||||
result.checks.find(({ name }) => name === "native_decompiler"),
|
||||
).toMatchObject({
|
||||
status: "failed",
|
||||
detail: expect.stringContaining("unrecognized os.arch="),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a JDK whose os.name selects a different host OS", () => {
|
||||
const result = inspectGhidraInstallation(
|
||||
{
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
architecture: "arm64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({
|
||||
...JAVA,
|
||||
osArch: "aarch64",
|
||||
osName: "Windows 11",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
if (result.status !== "unavailable") return;
|
||||
expect(
|
||||
result.checks.find(({ name }) => name === "native_decompiler"),
|
||||
).toMatchObject({
|
||||
status: "failed",
|
||||
code: "executable_missing",
|
||||
detail: expect.stringContaining("JVM reports os.name=Windows 11"),
|
||||
remediation: expect.stringContaining("JAVA_HOME"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra JVM OS admission", () => {
|
||||
it("rejects a successful Java probe that omits os.name", () => {
|
||||
const { osName: _observedOsName, ...withoutOsName } = JAVA;
|
||||
const result = inspectGhidraInstallation(
|
||||
{
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "linux",
|
||||
architecture: "x64",
|
||||
},
|
||||
host({ probeJava: () => withoutOsName }),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
expect(
|
||||
result.checks.find(({ name }) => name === "native_decompiler"),
|
||||
).toMatchObject({
|
||||
status: "failed",
|
||||
detail: expect.stringContaining("did not report os.name"),
|
||||
});
|
||||
expect(projectGhidraDoctorInspection(result).available).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["SunOS", "FreeBSD", ""])(
|
||||
"refuses an unrecognized JVM os.name (%s) instead of assuming alignment",
|
||||
(osName) => {
|
||||
const result = inspectGhidraInstallation(
|
||||
{
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "linux",
|
||||
architecture: "x64",
|
||||
},
|
||||
host({
|
||||
probeJava: () => ({ ...JAVA, osName }),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
if (result.status !== "unavailable") return;
|
||||
expect(
|
||||
result.checks.find(({ name }) => name === "native_decompiler"),
|
||||
).toMatchObject({
|
||||
status: "failed",
|
||||
detail: expect.stringContaining("os.name="),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Ghidra Java probe os.arch extraction", () => {
|
||||
it("extracts os.arch through the real Java probe into the installation diagnostics", () => {
|
||||
vi.mocked(spawnSync).mockReturnValue({
|
||||
status: 0,
|
||||
stdout: "",
|
||||
stderr: [
|
||||
"Property settings:",
|
||||
" file.encoding = UTF-8",
|
||||
" java.home = /usr/lib/jvm/jdk-21",
|
||||
" java.version = 21.0.11",
|
||||
" java.vm.name = OpenJDK 64-Bit Server VM",
|
||||
" os.arch = x86_64",
|
||||
" os.name = Linux",
|
||||
" sun.arch.data.model = 64",
|
||||
"",
|
||||
].join("\n"),
|
||||
} as never);
|
||||
try {
|
||||
// No host override: the real system host runs the real probeJava
|
||||
// against the stubbed spawnSync.
|
||||
const inspection = inspectGhidraInstallation({
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
javaHome: "/usr/lib/jvm/jdk-21",
|
||||
platform: "linux",
|
||||
architecture: "x64",
|
||||
});
|
||||
expect(ghidraInstallationDiagnostics(inspection)).toMatchObject({
|
||||
java_os_arch: "x86_64",
|
||||
java_os_name: "Linux",
|
||||
});
|
||||
} finally {
|
||||
vi.mocked(spawnSync).mockReset();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,6 +203,17 @@ interface GhidraInstallationObservation {
|
||||
readonly javaCommand: string;
|
||||
/** Observed java.vm.name; omitted when the probe cannot establish VM identity. */
|
||||
readonly javaVmName?: string;
|
||||
/**
|
||||
* Observed JVM os.arch; omitted when the probe cannot establish it. Ghidra
|
||||
* selects its native decompiler directory from this value.
|
||||
*/
|
||||
readonly javaOsArch?: string;
|
||||
/**
|
||||
* Observed JVM os.name; omitted when the probe cannot establish it. Ghidra
|
||||
* selects its native decompiler OS directory from the JVM OS/architecture
|
||||
* pair, so a non-host value selects an unvalidated directory.
|
||||
*/
|
||||
readonly javaOsName?: string;
|
||||
readonly checks: readonly GhidraInstallationCheck[];
|
||||
}
|
||||
|
||||
@@ -244,6 +255,17 @@ export interface GhidraJavaObservation {
|
||||
readonly bits: number;
|
||||
readonly runtime: "jdk" | "jre";
|
||||
readonly vmName?: string;
|
||||
/**
|
||||
* Observed JVM os.arch property, which Ghidra uses to select its native
|
||||
* decompiler directory. Omitted when the probe cannot establish it.
|
||||
*/
|
||||
readonly osArch?: string;
|
||||
/**
|
||||
* Observed JVM os.name property, which Ghidra uses with os.arch to select
|
||||
* its native decompiler OS directory. Omitted when the probe cannot
|
||||
* establish it.
|
||||
*/
|
||||
readonly osName?: string;
|
||||
}
|
||||
|
||||
/** A bounded executable-header probe: a small prefix plus the whole file size. */
|
||||
@@ -324,6 +346,12 @@ export const inspectGhidraInstallation = (
|
||||
...(coordinates.java?.vmName === undefined
|
||||
? {}
|
||||
: { javaVmName: coordinates.java.vmName }),
|
||||
...(coordinates.java?.osArch === undefined
|
||||
? {}
|
||||
: { javaOsArch: coordinates.java.osArch }),
|
||||
...(coordinates.java?.osName === undefined
|
||||
? {}
|
||||
: { javaOsName: coordinates.java.osName }),
|
||||
checks,
|
||||
};
|
||||
if (failed !== undefined)
|
||||
@@ -555,6 +583,8 @@ export const ghidraInstallationDiagnostics = (
|
||||
java_home: inspection.javaHome,
|
||||
java_version: inspection.javaVersion,
|
||||
java_vm_name: inspection.javaVmName ?? null,
|
||||
java_os_arch: inspection.javaOsArch ?? null,
|
||||
java_os_name: inspection.javaOsName ?? null,
|
||||
platform: inspection.platform,
|
||||
architecture: inspection.architecture,
|
||||
checks: inspection.checks.map((check) => ({
|
||||
@@ -632,6 +662,61 @@ const nativeDecompilerCheck = ({
|
||||
architecture,
|
||||
host.executableHeader?.(path),
|
||||
);
|
||||
// Ghidra resolves its native decompiler directory from the JVM's os.arch,
|
||||
// not the host process architecture. A JDK running under emulation (for
|
||||
// example x86_64 under Rosetta on Apple silicon) selects a directory that
|
||||
// was never validated, so every analysis fails after a passing doctor.
|
||||
// Ghidra resolves the OS directory from the JVM os.name/os.arch pair, so a
|
||||
// JVM reporting a non-host os.name (for example through -Dos.name=Windows)
|
||||
// selects a different OS directory even when os.arch matches.
|
||||
const osArch = coordinates.java?.osArch;
|
||||
const osName = coordinates.java?.osName;
|
||||
const selectedDirectory = jvmOsArchNativeDirectory(platform, osArch);
|
||||
const validatedDirectory = NATIVE_PLATFORMS[platform]?.[architecture];
|
||||
// Without a JVM observation the java check reports the missing runtime;
|
||||
// the decompiler directory cannot be selected, so this check defers to it.
|
||||
if (coordinates.java !== undefined) {
|
||||
if (selectedDirectory === undefined)
|
||||
return installationCheck({
|
||||
name: "native_decompiler",
|
||||
passed: false,
|
||||
code: "executable_missing",
|
||||
detail:
|
||||
osArch === undefined
|
||||
? "The Java probe did not report os.arch; the JVM-selected native decompiler directory is unknown and cannot be matched to the validated host directory"
|
||||
: `JVM reports unrecognized os.arch=${osArch}; its selected native decompiler directory is unknown and cannot be matched to the validated host directory`,
|
||||
remediation: `Point JAVA_HOME at a JDK whose reported architecture maps to the validated Ghidra decompiler (${validatedDirectory ?? "unsupported host"}). REA does not build or install native tools.`,
|
||||
});
|
||||
if (
|
||||
validatedDirectory !== undefined &&
|
||||
selectedDirectory !== validatedDirectory &&
|
||||
path !== null
|
||||
)
|
||||
return installationCheck({
|
||||
name: "native_decompiler",
|
||||
passed: false,
|
||||
code: "executable_missing",
|
||||
detail: `JVM reports os.arch=${osArch}, so Ghidra selects its native decompiler from os/${selectedDirectory}, but the validated decompiler targets ${validatedDirectory} (host ${platform}/${architecture})`,
|
||||
remediation: `Point JAVA_HOME at a JDK whose architecture matches the built decompiler (${validatedDirectory}). REA does not build or install native tools.`,
|
||||
});
|
||||
if (osName === undefined)
|
||||
return installationCheck({
|
||||
name: "native_decompiler",
|
||||
passed: false,
|
||||
code: "executable_missing",
|
||||
detail:
|
||||
"The Java probe did not report os.name; the JVM-selected native decompiler OS directory is unknown and cannot be matched to the validated host platform",
|
||||
remediation: `Point JAVA_HOME at a JDK whose reported operating system matches the host platform (${platform}). REA does not build or install native tools.`,
|
||||
});
|
||||
if (!jvmOsNameMatchesPlatform(platform, osName) && path !== null)
|
||||
return installationCheck({
|
||||
name: "native_decompiler",
|
||||
passed: false,
|
||||
code: "executable_missing",
|
||||
detail: `JVM reports os.name=${osName}, which does not match the validated host platform ${platform} (host ${platform}/${architecture})`,
|
||||
remediation: `Point JAVA_HOME at a JDK whose reported operating system matches the host platform (${platform}). REA does not build or install native tools.`,
|
||||
});
|
||||
}
|
||||
return installationCheck({
|
||||
name: "native_decompiler",
|
||||
passed: path !== null && compatibility?.status !== "incompatible",
|
||||
@@ -646,6 +731,43 @@ const nativeDecompilerCheck = ({
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a JVM os.name value selects the host platform's native OS directory.
|
||||
* Unknown values never align with the validated host directory; admission
|
||||
* must not treat them as matching.
|
||||
*/
|
||||
const jvmOsNameMatchesPlatform = (
|
||||
platform: NodeJS.Platform,
|
||||
osName: string,
|
||||
): boolean => {
|
||||
const normalized = osName.toLowerCase();
|
||||
if (platform === "win32") return normalized.startsWith("windows");
|
||||
if (platform === "darwin")
|
||||
return normalized.startsWith("mac") || normalized.startsWith("darwin");
|
||||
if (platform === "linux") return normalized.startsWith("linux");
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Map a JVM os.arch value to the Ghidra native directory that JVM selects.
|
||||
* Unknown values stay unknown; admission must not treat them as aligned with
|
||||
* the validated host directory.
|
||||
*/
|
||||
const jvmOsArchNativeDirectory = (
|
||||
platform: NodeJS.Platform,
|
||||
osArch: string | undefined,
|
||||
): string | undefined => {
|
||||
if (osArch === undefined) return undefined;
|
||||
const normalized = osArch.toLowerCase();
|
||||
const node: NodeJS.Architecture | undefined =
|
||||
normalized === "x86_64" || normalized === "amd64" || normalized === "x64"
|
||||
? "x64"
|
||||
: normalized === "aarch64" || normalized === "arm64"
|
||||
? "arm64"
|
||||
: undefined;
|
||||
return node === undefined ? undefined : NATIVE_PLATFORMS[platform]?.[node];
|
||||
};
|
||||
|
||||
const versionCheck = (
|
||||
properties: GhidraApplicationProperties | undefined,
|
||||
): GhidraInstallationCheck => {
|
||||
@@ -781,6 +903,8 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
|
||||
const version = javaProperty(output, "java.version");
|
||||
const home = javaProperty(output, "java.home");
|
||||
const vmName = javaProperty(output, "java.vm.name");
|
||||
const osArch = javaProperty(output, "os.arch");
|
||||
const osName = javaProperty(output, "os.name");
|
||||
const bits = Number.parseInt(
|
||||
javaProperty(output, "sun.arch.data.model") ?? "0",
|
||||
10,
|
||||
@@ -791,6 +915,8 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
|
||||
major: javaMajor(version),
|
||||
home,
|
||||
...(vmName === null ? {} : { vmName }),
|
||||
...(osArch === null ? {} : { osArch }),
|
||||
...(osName === null ? {} : { osName }),
|
||||
bits,
|
||||
runtime: executablePath(
|
||||
(process.platform === "win32" ? win32 : posix).join(
|
||||
|
||||
@@ -2,11 +2,43 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import { dirname, join } from "node:path";
|
||||
import { execFileOutput } from "../process/ExecFileOutput.js";
|
||||
|
||||
/** JVM option environments remove quotes, including quotes inside an option. */
|
||||
const jvmEnvironmentArguments = (value: string): readonly string[] => {
|
||||
const arguments_: string[] = [];
|
||||
let argument = "";
|
||||
let quote: "'" | '"' | undefined;
|
||||
for (const character of value) {
|
||||
if (quote !== undefined) {
|
||||
if (character === quote) quote = undefined;
|
||||
else argument += character;
|
||||
} else if (character === "'" || character === '"') quote = character;
|
||||
else if (/\s/u.test(character)) {
|
||||
if (argument !== "") arguments_.push(argument);
|
||||
argument = "";
|
||||
} else argument += character;
|
||||
}
|
||||
if (quote !== undefined)
|
||||
throw new Error(
|
||||
"Invalid quoted Ghidra LaunchSupport JVM environment option",
|
||||
);
|
||||
if (argument !== "") arguments_.push(argument);
|
||||
return arguments_;
|
||||
};
|
||||
|
||||
const rejectJvmPlatformOverrides = (arguments_: readonly string[]): void => {
|
||||
if (
|
||||
arguments_.some((argument) => /^-Dos\.(?:arch|name)(?:=|$)/u.test(argument))
|
||||
)
|
||||
throw new Error(
|
||||
"Ghidra LaunchSupport may not override os.arch or os.name; the native decompiler was validated for the inspected JVM platform.",
|
||||
);
|
||||
};
|
||||
|
||||
/** Launch coordinates obtained from Ghidra's own release configuration helper. */
|
||||
export const ghidraJavaLaunch = async (options: {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly analyzeHeadlessPath: string;
|
||||
readonly javaHome: string;
|
||||
readonly javaHome?: string;
|
||||
readonly javaVmName?: string;
|
||||
readonly homeRoot: string;
|
||||
readonly tempRoot: string;
|
||||
@@ -21,7 +53,17 @@ export const ghidraJavaLaunch = async (options: {
|
||||
}> => {
|
||||
const supportRoot = dirname(options.analyzeHeadlessPath);
|
||||
const installRoot = dirname(supportRoot);
|
||||
const command = join(options.javaHome, "bin", "java");
|
||||
const selectedJavaHome = options.javaHome ?? options.environment.JAVA_HOME;
|
||||
const command =
|
||||
selectedJavaHome === undefined
|
||||
? options.platform === "win32"
|
||||
? "java.exe"
|
||||
: "java"
|
||||
: join(
|
||||
selectedJavaHome,
|
||||
"bin",
|
||||
options.platform === "win32" ? "java.exe" : "java",
|
||||
);
|
||||
const isolatedProperties = [
|
||||
`-Duser.home=${options.homeRoot}`,
|
||||
`-Djava.io.tmpdir=${options.tempRoot}`,
|
||||
@@ -48,6 +90,9 @@ export const ghidraJavaLaunch = async (options: {
|
||||
[...supportArguments, "-envvars"],
|
||||
{
|
||||
env: environment,
|
||||
// Wait for the helper's exit on abort so a cancelled or timed-out
|
||||
// startup never leaves the probe running without an owning process.
|
||||
stopSignal: "SIGTERM",
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
},
|
||||
);
|
||||
@@ -65,11 +110,25 @@ export const ghidraJavaLaunch = async (options: {
|
||||
if ((environment[key] ?? "") === "")
|
||||
environment[key] = line.slice(separator + 1);
|
||||
}
|
||||
// The merged environment becomes the analysis JVM's environment, and the
|
||||
// JVM applies these options variables automatically. An injected
|
||||
// -Dos.arch/-Dos.name there selects an unvalidated native directory without
|
||||
// appearing in vmArguments, so reject the effective values as well.
|
||||
for (const key of [
|
||||
"JAVA_TOOL_OPTIONS",
|
||||
"_JAVA_OPTIONS",
|
||||
"JDK_JAVA_OPTIONS",
|
||||
] as const) {
|
||||
rejectJvmPlatformOverrides(jvmEnvironmentArguments(environment[key] ?? ""));
|
||||
}
|
||||
const configuredArguments = await execFileOutput(
|
||||
command,
|
||||
[...supportArguments, "-vmargs"],
|
||||
{
|
||||
env: environment,
|
||||
// Wait for the helper's exit on abort so a cancelled or timed-out
|
||||
// startup never leaves the probe running without an owning process.
|
||||
stopSignal: "SIGTERM",
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
},
|
||||
);
|
||||
@@ -83,6 +142,7 @@ export const ghidraJavaLaunch = async (options: {
|
||||
)
|
||||
)
|
||||
throw new Error("Invalid Ghidra LaunchSupport JVM argument");
|
||||
rejectJvmPlatformOverrides(vmArguments);
|
||||
return {
|
||||
command,
|
||||
environment,
|
||||
|
||||
@@ -172,6 +172,8 @@ export interface GhidraHeadlessLauncherOptions {
|
||||
readonly mipsSoftFloat?: true;
|
||||
/** Spawn seam for provider-boundary lifecycle tests. */
|
||||
readonly spawnProcess?: typeof spawnOwnedProviderProcess;
|
||||
/** LaunchSupport probe seam for Java-free launcher fixtures. */
|
||||
readonly javaLaunch?: typeof ghidraJavaLaunch;
|
||||
}
|
||||
|
||||
/** Launch Ghidra without copying scripts into or modifying its installation. */
|
||||
@@ -303,43 +305,46 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
throw new GhidraLaunchError(
|
||||
"POSIX Ghidra requires its inspected JDK home",
|
||||
);
|
||||
const inspectedJavaLaunch = await (
|
||||
this.options.javaLaunch ?? ghidraJavaLaunch
|
||||
)({
|
||||
platform,
|
||||
analyzeHeadlessPath: this.options.analyzeHeadlessPath,
|
||||
...(this.options.javaHome === undefined
|
||||
? {}
|
||||
: { javaHome: this.options.javaHome }),
|
||||
...(this.options.javaVmName === undefined
|
||||
? {}
|
||||
: { javaVmName: this.options.javaVmName }),
|
||||
homeRoot: paths.homeRoot,
|
||||
tempRoot: paths.tempRoot,
|
||||
arguments: headlessArguments,
|
||||
environment,
|
||||
onStderr: (mode, stderr) => {
|
||||
if (stderr === "") return;
|
||||
const bytes = Buffer.byteLength(stderr);
|
||||
const redacted = redactCapturedTransportCredential(
|
||||
{ text: stderr, bytes, observedBytes: bytes },
|
||||
session.token,
|
||||
"[REDACTED]",
|
||||
);
|
||||
const bounded = boundedUtf8Text(
|
||||
redacted,
|
||||
GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES,
|
||||
);
|
||||
launchSupport.push({
|
||||
mode,
|
||||
stderr: bounded.text,
|
||||
stderr_bytes: bytes,
|
||||
stderr_truncated: bounded.truncated,
|
||||
});
|
||||
},
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
});
|
||||
const command =
|
||||
platform !== "win32" && this.options.javaHome !== undefined
|
||||
? await ghidraJavaLaunch({
|
||||
platform,
|
||||
analyzeHeadlessPath: this.options.analyzeHeadlessPath,
|
||||
javaHome: this.options.javaHome,
|
||||
...(this.options.javaVmName === undefined
|
||||
? {}
|
||||
: { javaVmName: this.options.javaVmName }),
|
||||
homeRoot: paths.homeRoot,
|
||||
tempRoot: paths.tempRoot,
|
||||
arguments: headlessArguments,
|
||||
environment,
|
||||
onStderr: (mode, stderr) => {
|
||||
if (stderr === "") return;
|
||||
const bytes = Buffer.byteLength(stderr);
|
||||
const redacted = redactCapturedTransportCredential(
|
||||
{ text: stderr, bytes, observedBytes: bytes },
|
||||
session.token,
|
||||
"[REDACTED]",
|
||||
);
|
||||
const bounded = boundedUtf8Text(
|
||||
redacted,
|
||||
GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES,
|
||||
);
|
||||
launchSupport.push({
|
||||
mode,
|
||||
stderr: bounded.text,
|
||||
stderr_bytes: bytes,
|
||||
stderr_truncated: bounded.truncated,
|
||||
});
|
||||
},
|
||||
...(options.signal === undefined
|
||||
? {}
|
||||
: { signal: options.signal }),
|
||||
})
|
||||
: { ...scriptCommand, environment };
|
||||
platform === "win32"
|
||||
? { ...scriptCommand, environment }
|
||||
: inspectedJavaLaunch;
|
||||
started = await (this.options.spawnProcess ?? spawnOwnedProviderProcess)({
|
||||
command: command.command,
|
||||
arguments: command.arguments,
|
||||
|
||||
@@ -37,12 +37,16 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
|
||||
|
||||
const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
|
||||
const installationHost = (): GhidraInstallationHost => ({
|
||||
platform: "linux",
|
||||
const installationHost = (
|
||||
platform: NodeJS.Platform = "linux",
|
||||
): GhidraInstallationHost => ({
|
||||
platform,
|
||||
architecture: "x64",
|
||||
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: platform === "win32" ? "Windows 11" : "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
@@ -102,7 +106,7 @@ describe("Ghidra provider", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const windows = provider({ ...installationHost(), platform: "win32" });
|
||||
const windows = provider(installationHost("win32"));
|
||||
expect(windows.inspectTargetSupport(target).status).toBe("unsupported");
|
||||
});
|
||||
it("discovers the exact installation once without launching Ghidra", () => {
|
||||
@@ -112,6 +116,8 @@ describe("Ghidra provider", () => {
|
||||
probeJava: () => {
|
||||
probeCount += 1;
|
||||
return {
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
@@ -193,7 +199,7 @@ describe("Ghidra provider", () => {
|
||||
|
||||
describe("Ghidra raw-binary profile", () => {
|
||||
it("admits raw 6502 on Windows and binds its 16-bit language without mutation authority", async () => {
|
||||
const ghidra = provider({ ...installationHost(), platform: "win32" });
|
||||
const ghidra = provider(installationHost("win32"));
|
||||
const target: BinaryTarget = {
|
||||
path: "/tmp/6502.bin",
|
||||
sha256: "a".repeat(64),
|
||||
@@ -260,7 +266,7 @@ describe("Ghidra raw-binary profile", () => {
|
||||
expect(shifted.value.profile?.digest).not.toBe(
|
||||
resolved.value.profile.digest,
|
||||
);
|
||||
const windows = provider({ ...installationHost(), platform: "win32" });
|
||||
const windows = provider(installationHost("win32"));
|
||||
expect(windows.inspectTargetSupport(target).status).toBe("unsupported");
|
||||
});
|
||||
});
|
||||
@@ -302,7 +308,7 @@ describe("Ghidra Mach-O slice support", () => {
|
||||
|
||||
describe("Ghidra platform support", () => {
|
||||
it("commits the PE role and managed classification to profile identity", async () => {
|
||||
const ghidra = provider({ ...installationHost(), platform: "win32" });
|
||||
const ghidra = provider(installationHost("win32"));
|
||||
const application = peTarget("x86_64");
|
||||
const library = {
|
||||
...application,
|
||||
@@ -343,7 +349,7 @@ describe("Ghidra platform support", () => {
|
||||
});
|
||||
|
||||
it("keeps Windows annotation mutation unavailable independently of native controls", () => {
|
||||
const ghidra = provider({ ...installationHost(), platform: "win32" });
|
||||
const ghidra = provider(installationHost("win32"));
|
||||
expect(
|
||||
ghidra
|
||||
.capabilities()
|
||||
@@ -363,7 +369,7 @@ describe("Ghidra platform support", () => {
|
||||
reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON,
|
||||
});
|
||||
onTestFinished(() => native.mockRestore());
|
||||
const ghidra = provider({ ...installationHost(), platform: "win32" });
|
||||
const ghidra = provider(installationHost("win32"));
|
||||
const nativeApplication = peTarget("x86_64");
|
||||
|
||||
for (const architecture of ["x86", "x86_64"] as const)
|
||||
@@ -454,7 +460,7 @@ describe("Ghidra Windows PE fixture admission", () => {
|
||||
await promisify(execFile)(process.execPath, [
|
||||
join(root, "scripts/create-ghidra-windows-fixture.mjs"),
|
||||
]);
|
||||
const ghidra = provider({ ...installationHost(), platform: "win32" });
|
||||
const ghidra = provider(installationHost("win32"));
|
||||
for (const architecture of ["x86", "x86_64"] as const) {
|
||||
for (const dll of [false, true]) {
|
||||
const path = join(
|
||||
@@ -627,7 +633,7 @@ describe("Ghidra client projection", () => {
|
||||
expect(resolved.value.profile.parameters).toMatchObject({
|
||||
language_id: "auto-from-header",
|
||||
compiler_spec_id: "auto-default",
|
||||
call_relationship_coverage: "resolved-call-reference-diagnostics-v1",
|
||||
call_relationship_coverage: "function-body-call-coverage-v5",
|
||||
function_dossier_coverage: "observed-facets-and-call-relationships-v1",
|
||||
});
|
||||
|
||||
@@ -864,7 +870,16 @@ describe("Ghidra relationship coverage", () => {
|
||||
Promise.resolve(
|
||||
ok(
|
||||
operation === "procedure_callees"
|
||||
? { value: ["0x2000"], limitations: [] }
|
||||
? {
|
||||
value: ["0x2000"],
|
||||
limitations: [],
|
||||
relationship_coverage: {
|
||||
status: "complete",
|
||||
diagnostics: [
|
||||
"Only resolved Ghidra call references are returned; unresolved computed or indirect calls remain unknown, while function classifications distinguish thunks and externals.",
|
||||
],
|
||||
},
|
||||
}
|
||||
: { value: [], limitations: [] },
|
||||
),
|
||||
),
|
||||
@@ -1818,6 +1833,8 @@ describe("compatible Ghidra builds", () => {
|
||||
readText: () =>
|
||||
"application.version=12.1.2\napplication.java.min=21\napplication.java.max=\n",
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "27",
|
||||
major: 27,
|
||||
home: "/usr/lib/jvm/java-27-openjdk",
|
||||
|
||||
@@ -2,7 +2,6 @@ import type {
|
||||
CapabilityDescriptor,
|
||||
ProviderIdentity,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
|
||||
import {
|
||||
hasWindowsNativeAuthority,
|
||||
windowsNativeAuthorityUnavailableReason,
|
||||
@@ -44,22 +43,6 @@ const stringInventoryLimitation =
|
||||
const searchLimitation =
|
||||
"Regex mode uses Java Pattern semantics. Matcher or compiler stack exhaustion returns a resource constraint without closing the session; literal mode avoids regex recursion and returns complete matching values.";
|
||||
|
||||
const resolvedCallReferenceLimitation =
|
||||
"Only resolved Ghidra call references are returned; unresolved computed or indirect calls remain unknown, while function classifications distinguish thunks and externals.";
|
||||
|
||||
/** Relationship-only caveats belong to each observed caller/callee result. */
|
||||
export const relationshipCoverageFor = (
|
||||
operation: string,
|
||||
): RelationshipCoverage | undefined =>
|
||||
operation === "procedure_callers" ||
|
||||
operation === "procedure_callees" ||
|
||||
operation === "analyze_function"
|
||||
? {
|
||||
status: "complete",
|
||||
diagnostics: [resolvedCallReferenceLimitation],
|
||||
}
|
||||
: undefined;
|
||||
|
||||
/** Limitation text for one admitted operation, including the common base. */
|
||||
export const limitationsFor = (operation: string): readonly string[] => {
|
||||
const common = [
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { EvidenceLocation } from "../domain/evidence.js";
|
||||
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
@@ -86,7 +87,6 @@ import {
|
||||
healthLimitations,
|
||||
windowsP0Limitations,
|
||||
limitationsFor,
|
||||
relationshipCoverageFor,
|
||||
} from "./GhidraProviderCapabilities.js";
|
||||
import type { GhidraSessionError } from "./GhidraSessionError.js";
|
||||
import {
|
||||
@@ -733,8 +733,8 @@ export const createGhidraProviderClient = (input: {
|
||||
rawResult: JsonValue,
|
||||
observationLimitations: readonly string[] = [],
|
||||
locations: readonly EvidenceLocation[] = [],
|
||||
relationshipCoverage?: RelationshipCoverage,
|
||||
) => {
|
||||
const relationshipCoverage = relationshipCoverageFor(operation);
|
||||
return createAnalysisExecution(normalized, committedProfile.provider, {
|
||||
rawResult,
|
||||
locations,
|
||||
@@ -838,12 +838,14 @@ export const createGhidraProviderClient = (input: {
|
||||
let normalized: JsonValue;
|
||||
let observationLimitations: readonly string[] = [];
|
||||
let observationLocations: readonly EvidenceLocation[] = [];
|
||||
let relationshipCoverage: RelationshipCoverage | undefined;
|
||||
if (isGhidraFunctionOperation(operation)) {
|
||||
const result = parseGhidraFunctionResult(operation, called.value);
|
||||
if (!result.ok) return result;
|
||||
normalized = result.value.value;
|
||||
observationLimitations = result.value.limitations;
|
||||
observationLocations = result.value.locations ?? [];
|
||||
relationshipCoverage = result.value.relationshipCoverage;
|
||||
} else {
|
||||
const result = parseGhidraInventoryResult(operation, called.value);
|
||||
if (!result.ok) return result;
|
||||
@@ -939,6 +941,7 @@ export const createGhidraProviderClient = (input: {
|
||||
called.value,
|
||||
observationLimitations,
|
||||
observationLocations,
|
||||
relationshipCoverage,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import { silentLogger } from "../logger.js";
|
||||
import { GhidraProvider } from "./GhidraProvider.js";
|
||||
import { GhidraSessionError } from "./GhidraSessionError.js";
|
||||
import { GHIDRA_SESSION_CAPABILITIES } from "./GhidraSessionValues.js";
|
||||
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
|
||||
|
||||
const TARGET: BinaryTarget = {
|
||||
path: "/tmp/ghidra-cache-fixture",
|
||||
@@ -23,7 +24,7 @@ const TARGET: BinaryTarget = {
|
||||
|
||||
// Exercise the real provider descriptors, profile, input/output projection and
|
||||
// session cache; only the installed Ghidra process is replaced at its seam.
|
||||
const fixture = (version = "12.1.4") => {
|
||||
const fixture = (version = "12.1.4", coverage?: RelationshipCoverage) => {
|
||||
const config = parseConfig({ GHIDRA_INSTALL_DIR: "/opt/ghidra" });
|
||||
if (!config.ok) throw config.error;
|
||||
const calls: string[] = [];
|
||||
@@ -43,6 +44,8 @@ const fixture = (version = "12.1.4") => {
|
||||
`application.version=${version}\napplication.java.min=21\n`,
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
@@ -111,7 +114,13 @@ const fixture = (version = "12.1.4") => {
|
||||
return ok(
|
||||
operation === "procedure_pseudo_code" ||
|
||||
operation === "analyze_function"
|
||||
? { value, limitations: [] }
|
||||
? {
|
||||
value,
|
||||
limitations: [],
|
||||
...(operation === "analyze_function" && coverage !== undefined
|
||||
? { relationship_coverage: coverage }
|
||||
: {}),
|
||||
}
|
||||
: value,
|
||||
);
|
||||
},
|
||||
@@ -131,42 +140,119 @@ const open = async (session: ReturnType<typeof fixture>["session"]) => {
|
||||
expect((await session.openResolvedTarget(preview.value)).ok).toBe(true);
|
||||
};
|
||||
|
||||
describe("Ghidra snapshot cache", () => {
|
||||
it("refuses snapshots whose profile predates typed incoming references", async () => {
|
||||
const initial = fixture();
|
||||
await open(initial.session);
|
||||
const snapshot = initial.session.exportAnalysisSnapshot();
|
||||
if (!snapshot.ok) throw snapshot.error;
|
||||
await initial.session.close();
|
||||
const profile = snapshot.value.binding.analysis_profile;
|
||||
expect(profile.parameters.instruction_references).toBe(
|
||||
"exact-address-incoming-v1",
|
||||
);
|
||||
const legacy = {
|
||||
...snapshot.value,
|
||||
binding: {
|
||||
...snapshot.value.binding,
|
||||
analysis_profile: createAnalysisProfile(
|
||||
profile.provider,
|
||||
Object.fromEntries(
|
||||
Object.entries(profile.parameters).filter(
|
||||
([key]) => key !== "instruction_references",
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
};
|
||||
const replay = fixture();
|
||||
const imported = replay.session.importAnalysisSnapshot(legacy);
|
||||
if (!imported.ok) throw imported.error;
|
||||
expect(await replay.session.previewTarget(TARGET)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "EvidenceIntegrityError" },
|
||||
});
|
||||
expect(replay.calls).toEqual([]);
|
||||
await replay.session.close();
|
||||
});
|
||||
describe("Ghidra relationship coverage replay", () => {
|
||||
it.each([
|
||||
undefined,
|
||||
{ status: "complete", diagnostics: ["Resolved static references only."] },
|
||||
{ status: "partial", limitations: ["Instruction coverage was truncated."] },
|
||||
{ status: "unknown", reason: "Relationship inventory was unavailable." },
|
||||
{
|
||||
status: "partial",
|
||||
limitations: ["Missing callers."],
|
||||
limited_directions: ["callers"],
|
||||
},
|
||||
] satisfies (RelationshipCoverage | undefined)[])(
|
||||
"preserves relationship coverage %j through live execution, cache and snapshot replay",
|
||||
async (coverage) => {
|
||||
const initial = fixture("12.1.4", coverage);
|
||||
await open(initial.session);
|
||||
const parameters = { procedure: "0x1000" };
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const execution = await initial.session.execute(
|
||||
"analyze_function",
|
||||
parameters,
|
||||
);
|
||||
if (!execution.ok) throw execution.error;
|
||||
expect(execution.value.relationshipCoverage).toEqual(coverage);
|
||||
}
|
||||
expect(initial.calls).toEqual(["analyze_function"]);
|
||||
const snapshot = initial.session.exportAnalysisSnapshot();
|
||||
if (!snapshot.ok) throw snapshot.error;
|
||||
await initial.session.close();
|
||||
const replay = fixture();
|
||||
expect(replay.session.importAnalysisSnapshot(snapshot.value).ok).toBe(
|
||||
true,
|
||||
);
|
||||
await open(replay.session);
|
||||
const execution = await replay.session.execute(
|
||||
"analyze_function",
|
||||
parameters,
|
||||
);
|
||||
if (!execution.ok) throw execution.error;
|
||||
expect(execution.value.relationshipCoverage ?? undefined).toEqual(
|
||||
coverage,
|
||||
);
|
||||
expect(replay.calls).toEqual([]);
|
||||
await replay.session.close();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Ghidra snapshot profile admission", () => {
|
||||
it.each([
|
||||
{
|
||||
parameter: "instruction_references",
|
||||
current: "exact-address-incoming-v1",
|
||||
legacy: undefined,
|
||||
},
|
||||
{
|
||||
parameter: "call_relationship_coverage",
|
||||
current: "function-body-call-coverage-v5",
|
||||
legacy: "resolved-call-reference-diagnostics-v1",
|
||||
},
|
||||
{
|
||||
parameter: "call_relationship_coverage",
|
||||
current: "function-body-call-coverage-v5",
|
||||
legacy: "decoded-call-target-coverage-v2",
|
||||
},
|
||||
{
|
||||
parameter: "call_relationship_coverage",
|
||||
current: "function-body-call-coverage-v5",
|
||||
legacy: "decoded-call-target-coverage-v3",
|
||||
},
|
||||
{
|
||||
parameter: "call_relationship_coverage",
|
||||
current: "function-body-call-coverage-v5",
|
||||
legacy: "function-body-call-coverage-v4",
|
||||
},
|
||||
])(
|
||||
"refuses snapshots with outdated $parameter semantics",
|
||||
async ({ parameter, current, legacy: prior }) => {
|
||||
const initial = fixture();
|
||||
await open(initial.session);
|
||||
const snapshot = initial.session.exportAnalysisSnapshot();
|
||||
if (!snapshot.ok) throw snapshot.error;
|
||||
await initial.session.close();
|
||||
const profile = snapshot.value.binding.analysis_profile;
|
||||
expect(profile.parameters[parameter]).toBe(current);
|
||||
const legacy = {
|
||||
...snapshot.value,
|
||||
binding: {
|
||||
...snapshot.value.binding,
|
||||
analysis_profile: createAnalysisProfile(profile.provider, {
|
||||
...Object.fromEntries(
|
||||
Object.entries(profile.parameters).filter(
|
||||
([key]) => key !== parameter,
|
||||
),
|
||||
),
|
||||
...(prior === undefined ? {} : { [parameter]: prior }),
|
||||
}),
|
||||
},
|
||||
};
|
||||
const replay = fixture();
|
||||
const imported = replay.session.importAnalysisSnapshot(legacy);
|
||||
if (!imported.ok) throw imported.error;
|
||||
expect(await replay.session.previewTarget(TARGET)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "EvidenceIntegrityError" },
|
||||
});
|
||||
expect(replay.calls).toEqual([]);
|
||||
await replay.session.close();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Ghidra snapshot cache", () => {
|
||||
it.each([
|
||||
{ operation: "analyze_function", parameters: { procedure: "0x1000" } },
|
||||
{
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import type {
|
||||
PendingDirectory,
|
||||
ReferenceSourceEntry,
|
||||
ReferenceSourceEntryKind,
|
||||
ReferenceSourceExclusionKind,
|
||||
ReferenceSourceResult,
|
||||
TraversalState,
|
||||
} from "./ReferenceSourceReaderTypes.js";
|
||||
@@ -201,6 +201,13 @@ const processEntry = async (
|
||||
const metadata = await readMetadata(absolute);
|
||||
if (isAborted(state.signal)) return { ok: false, error: cancelled() };
|
||||
if (!metadata.ok) {
|
||||
const excluded = applyExclusion(state.shouldExclude, path, "unknown");
|
||||
if (!excluded.ok)
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("io", "Reference source exclusion check failed"),
|
||||
};
|
||||
if (excluded.value) return { ok: true, value: undefined };
|
||||
state.entries.push(entryFailure(path, "unknown", "io", metadata.message));
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
@@ -371,10 +378,10 @@ const readSymlinkTarget = async (
|
||||
|
||||
const applyExclusion = (
|
||||
shouldExclude:
|
||||
| ((path: string, kind: ReferenceSourceEntryKind) => boolean)
|
||||
| ((path: string, kind: ReferenceSourceExclusionKind) => boolean)
|
||||
| undefined,
|
||||
path: string,
|
||||
kind: ReferenceSourceEntryKind,
|
||||
kind: ReferenceSourceExclusionKind,
|
||||
): { readonly ok: true; readonly value: boolean } | { readonly ok: false } => {
|
||||
try {
|
||||
return { ok: true, value: shouldExclude?.(path, kind) === true };
|
||||
|
||||
@@ -29,6 +29,9 @@ const FILESYSTEM_ERROR_CODES = new Set([
|
||||
"EISDIR",
|
||||
"ELOOP",
|
||||
"EMFILE",
|
||||
// A traversed child can exceed the platform's absolute path limit even
|
||||
// though the selected root and every component are valid.
|
||||
"ENAMETOOLONG",
|
||||
"ENFILE",
|
||||
"ENOENT",
|
||||
"ENOTDIR",
|
||||
@@ -44,6 +47,8 @@ export const filesystemFailureDetail = (
|
||||
): string | undefined => {
|
||||
if (!(cause instanceof Error)) return undefined;
|
||||
const code: unknown = Reflect.get(cause, "code");
|
||||
if (code === "ENAMETOOLONG")
|
||||
return `${operation}: ENAMETOOLONG (entry path exceeds the operating system path limit)`;
|
||||
return typeof code === "string" && FILESYSTEM_ERROR_CODES.has(code)
|
||||
? `${operation}: ${cause.message}`
|
||||
: undefined;
|
||||
|
||||
@@ -10,11 +10,17 @@ export type ReferenceSourceEntryKind =
|
||||
| "symlink"
|
||||
| "other";
|
||||
|
||||
/**
|
||||
* Kind offered to exclusion predicates. `unknown` marks an entry whose
|
||||
* metadata could not be read, so its concrete kind cannot be claimed.
|
||||
*/
|
||||
export type ReferenceSourceExclusionKind = ReferenceSourceEntryKind | "unknown";
|
||||
|
||||
export interface ReferenceSourceReaderOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly shouldExclude?: (
|
||||
path: string,
|
||||
kind: ReferenceSourceEntryKind,
|
||||
kind: ReferenceSourceExclusionKind,
|
||||
) => boolean;
|
||||
}
|
||||
|
||||
@@ -84,7 +90,7 @@ export type TraversalState = {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly shouldExclude?: (
|
||||
path: string,
|
||||
kind: ReferenceSourceEntryKind,
|
||||
kind: ReferenceSourceExclusionKind,
|
||||
) => boolean;
|
||||
readonly entries: ReferenceSourceEntry[];
|
||||
readonly pending: PendingDirectory[];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect } from "vitest";
|
||||
@@ -68,6 +68,130 @@ cliTest(
|
||||
},
|
||||
);
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
const errorBody = (result: {
|
||||
readonly content: readonly { readonly type: string }[];
|
||||
}) => {
|
||||
const content = result.content.find((item) => item.type === "text");
|
||||
if (content?.type !== "text" || !("text" in content))
|
||||
throw new Error("Missing error response");
|
||||
return JSON.parse(String(content.text));
|
||||
};
|
||||
|
||||
const createElectronApplication = async (root: string) => {
|
||||
const application = join(root, "app");
|
||||
await mkdir(application);
|
||||
await writeFile(
|
||||
join(application, "package.json"),
|
||||
JSON.stringify({ name: "fixture", main: "main.js" }),
|
||||
);
|
||||
await writeFile(join(application, "main.js"), "");
|
||||
return application;
|
||||
};
|
||||
|
||||
cliTest.skipIf(isWindows)(
|
||||
"rejects a non-executable Electron executable_path before launch through CLI and MCP",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-electron-not-executable-");
|
||||
const application = await createElectronApplication(root);
|
||||
const executable = join(root, "not-an-executable.txt");
|
||||
await writeFile(executable, "plain text");
|
||||
await chmod(executable, 0o644);
|
||||
const request = {
|
||||
executable_path: executable,
|
||||
application_path: application,
|
||||
actions: [],
|
||||
};
|
||||
const requestPath = join(root, "input.json");
|
||||
await writeFile(requestPath, JSON.stringify(request));
|
||||
|
||||
const response = await cli.run({
|
||||
arguments: ["capture-electron-scenario", requestPath, "--format", "json"],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(response.exitCode).toBe(1);
|
||||
expect(response.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [{ path: ["executable_path"], reason: "invalid_value" }],
|
||||
},
|
||||
});
|
||||
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const result = await call("capture_electron_scenario", request);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(errorBody(result)).toMatchObject({
|
||||
error: {
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [{ path: ["executable_path"], reason: "invalid_value" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const session = await call("binary_session", {});
|
||||
expect(session.isError, JSON.stringify(session)).not.toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest.skipIf(isWindows)(
|
||||
"returns a typed failure when an executable exits before Electron starts and keeps the MCP server and its Evidence",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-electron-early-exit-");
|
||||
const application = await createElectronApplication(root);
|
||||
const executable = join(root, "exits-immediately");
|
||||
await writeFile(executable, "#!/bin/sh\nexit 0\n");
|
||||
await chmod(executable, 0o755);
|
||||
const request = {
|
||||
executable_path: executable,
|
||||
application_path: application,
|
||||
actions: [],
|
||||
};
|
||||
const requestPath = join(root, "input.json");
|
||||
await writeFile(requestPath, JSON.stringify(request));
|
||||
|
||||
const response = await cli.run({
|
||||
arguments: ["capture-electron-scenario", requestPath, "--format", "json"],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(response.exitCode).toBe(1);
|
||||
expect(response.json).toMatchObject({
|
||||
code: "execution_failure",
|
||||
details: { operation: "capture_electron_scenario" },
|
||||
});
|
||||
expect(response.stdout).toContain("Process failed to launch");
|
||||
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const analysis = await call("analyze_javascript_application", {
|
||||
input_path: application,
|
||||
});
|
||||
expect(analysis.isError, JSON.stringify(analysis)).not.toBe(true);
|
||||
const retained = async () => {
|
||||
const bundle = await call("get_evidence_bundle", {});
|
||||
expect(bundle.isError, JSON.stringify(bundle)).not.toBe(true);
|
||||
const structured = bundle.structuredContent as {
|
||||
result: { records: { evidence_id: string }[] };
|
||||
};
|
||||
return structured.result.records.map((record) => record.evidence_id);
|
||||
};
|
||||
const before = await retained();
|
||||
expect(before).toHaveLength(1);
|
||||
|
||||
const result = await call("capture_electron_scenario", request);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(errorBody(result)).toMatchObject({
|
||||
error: {
|
||||
code: "execution_failure",
|
||||
details: { operation: "capture_electron_scenario" },
|
||||
},
|
||||
});
|
||||
const session = await call("binary_session", {});
|
||||
expect(session.isError, JSON.stringify(session)).not.toBe(true);
|
||||
expect(await retained()).toEqual(before);
|
||||
},
|
||||
180_000,
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"rejects a file URL path field without resolving an invented local path",
|
||||
async ({ cli }) => {
|
||||
|
||||
@@ -279,26 +279,44 @@ it("preserves bounded instruction coverage separately from using-node coverage",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed method descriptors at the Android adapter boundary", async () => {
|
||||
const invalidSummary = {
|
||||
...summary,
|
||||
methods: summary.methods.map((method) => ({
|
||||
...method,
|
||||
dex_descriptor: "not-a-dex-descriptor",
|
||||
})),
|
||||
};
|
||||
await expect(
|
||||
analyzeJadxRequest(
|
||||
{
|
||||
json: () => Promise.resolve(invalidSummary),
|
||||
text: () => Promise.reject(new Error("unexpected text call")),
|
||||
it.each([
|
||||
"not-a-dex-descriptor",
|
||||
"(Lbad(name;)V",
|
||||
"(Lbad)name;)V",
|
||||
"()Lbad(name;",
|
||||
"()Lbad)name;",
|
||||
])(
|
||||
"rejects malformed DEX prototype %s at the adapter boundary",
|
||||
async (descriptor) => {
|
||||
const invalidSummary = {
|
||||
...summary,
|
||||
methods: summary.methods.map((method) => ({
|
||||
...method,
|
||||
dex_descriptor: descriptor,
|
||||
})),
|
||||
};
|
||||
await expect(
|
||||
analyzeJadxRequest(
|
||||
{
|
||||
json: () => Promise.resolve(invalidSummary),
|
||||
text: () => Promise.reject(new Error("unexpected text call")),
|
||||
},
|
||||
{
|
||||
operation: "inspect_android_class",
|
||||
input: { path: "/fixture.apk", class_name: "fixture.Target" },
|
||||
},
|
||||
{} as never,
|
||||
engine,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
_tag: "AnalysisOutputError",
|
||||
cause: {
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: ["methods", 0, "dex_descriptor"],
|
||||
}),
|
||||
]),
|
||||
},
|
||||
{
|
||||
operation: "inspect_android_class",
|
||||
input: { path: "/fixture.apk", class_name: "fixture.Target" },
|
||||
},
|
||||
{} as never,
|
||||
engine,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -15,6 +15,19 @@ const run = async (arguments_: readonly string[]): Promise<unknown> => {
|
||||
return JSON.parse(stdout);
|
||||
};
|
||||
|
||||
const runFailure = async (arguments_: readonly string[]): Promise<unknown> => {
|
||||
try {
|
||||
await run(arguments_);
|
||||
} catch (cause: unknown) {
|
||||
if (typeof cause !== "object" || cause === null || !("stdout" in cause))
|
||||
throw cause;
|
||||
const stdout = cause.stdout;
|
||||
if (typeof stdout !== "string") throw cause;
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
throw new Error("Expected the CLI command to fail");
|
||||
};
|
||||
|
||||
it.each(["analyze-javascript-application", "analyze"])(
|
||||
"%s carries the integrity policy into shared JavaScript analysis",
|
||||
async (command) => {
|
||||
@@ -46,3 +59,48 @@ it("rejects the JavaScript integrity policy on binary-only inspection", async ()
|
||||
stdout: expect.stringContaining("Unknown flag: --integrityPolicy"),
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["fail", "record-and-continue"] as const)(
|
||||
"rejects the explicitly selected %s integrity policy on a binary analyze target",
|
||||
async (policy) => {
|
||||
const failure = await runFailure([
|
||||
"analyze",
|
||||
"unused.bin",
|
||||
"--integrity-policy",
|
||||
policy,
|
||||
]);
|
||||
expect(failure).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [
|
||||
{
|
||||
path: ["integrity_policy"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"An integrity policy requires the static JavaScript ASAR/directory analysis lane; binary_overview accepts no input.",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("still routes a binary analyze target without an integrity policy", async () => {
|
||||
await expect(run(["analyze", "unused.bin"])).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stdout: expect.stringContaining("target does not exist"),
|
||||
});
|
||||
});
|
||||
|
||||
it("advertises the optional integrity policy without an undefined union", async () => {
|
||||
const schema = (await run(["analyze", "--schema"])) as {
|
||||
options?: { properties?: { integrityPolicy?: unknown } };
|
||||
};
|
||||
const policy = schema.options?.properties?.integrityPolicy;
|
||||
expect(policy).toMatchObject({
|
||||
type: "string",
|
||||
enum: ["fail", "record-and-continue"],
|
||||
description: "Behavior when declared application integrity does not match",
|
||||
});
|
||||
expect(JSON.stringify(policy)).not.toContain("undefined");
|
||||
});
|
||||
|
||||
@@ -67,6 +67,7 @@ describe("client configuration filesystem lifecycle", () => {
|
||||
"kimi",
|
||||
"vscode",
|
||||
"trae",
|
||||
"workbuddy",
|
||||
"grok_build",
|
||||
"omp",
|
||||
"pi",
|
||||
|
||||
@@ -55,6 +55,8 @@ const host: GhidraInstallationHost = {
|
||||
readText: () => "application.version=12.1.3\napplication.java.min=21\n",
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "21.0.12",
|
||||
major: 21,
|
||||
home: "/jdk-21",
|
||||
|
||||
@@ -133,3 +133,88 @@ describe("reference import native entry failure details", () => {
|
||||
).toMatchObject({ kind: "file", content_state: "hashed", limitations: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("reference import paths beyond the platform limit", () => {
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"reports an entry beyond the absolute path limit as an I/O failure",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-import-deep-path-");
|
||||
await writeFile(join(root, "kept.txt"), "k");
|
||||
// Relative creation lets the tree exceed PATH_MAX (1024 on macOS, 4096
|
||||
// on Linux) while each 200-byte component stays below NAME_MAX.
|
||||
const deepTree = (action: "create" | "remove") =>
|
||||
promisify(execFile)(process.execPath, [
|
||||
"-e",
|
||||
`const fs = require("node:fs");
|
||||
const names = Array.from({ length: 24 }, (_, i) => "d" + String(i).padStart(2, "0") + "x".repeat(197));
|
||||
process.chdir(${JSON.stringify(root)});
|
||||
if (${JSON.stringify(action)} === "create") {
|
||||
for (const name of names) { fs.mkdirSync(name); process.chdir(name); }
|
||||
fs.writeFileSync("deep.txt", "d");
|
||||
} else {
|
||||
for (const name of names) process.chdir(name);
|
||||
fs.rmSync("deep.txt");
|
||||
for (const name of [...names].reverse()) { process.chdir(".."); fs.rmdirSync(name); }
|
||||
}`,
|
||||
]);
|
||||
await deepTree("create");
|
||||
try {
|
||||
const read = await readReferenceSource(root);
|
||||
if (!read.ok) throw new Error(read.error.message);
|
||||
expect(read.value.entries).toContainEqual(
|
||||
expect.objectContaining({ path: "kept.txt", status: "read" }),
|
||||
);
|
||||
const failed = read.value.entries.find(
|
||||
(entry) => entry.status === "failed",
|
||||
);
|
||||
expect(failed).toMatchObject({
|
||||
status: "failed",
|
||||
code: "io",
|
||||
message: expect.stringContaining("ENAMETOOLONG"),
|
||||
});
|
||||
const filteredRead = await readReferenceSource(root, {
|
||||
shouldExclude: (path, kind) =>
|
||||
path === failed?.path && kind === "directory",
|
||||
});
|
||||
if (!filteredRead.ok) throw new Error(filteredRead.error.message);
|
||||
expect(
|
||||
filteredRead.value.entries.find(
|
||||
(entry) => entry.path === failed?.path,
|
||||
),
|
||||
).toMatchObject({ status: "failed", kind: "unknown", code: "io" });
|
||||
const imported = await importTree(root);
|
||||
if (!imported.ok) throw new Error(imported.error.message);
|
||||
expect(imported.value.entries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "kept.txt",
|
||||
content_state: "hashed",
|
||||
}),
|
||||
);
|
||||
const deep = imported.value.entries.find(
|
||||
(entry) => entry.path === failed?.path,
|
||||
);
|
||||
expect(deep?.limitations.join(" ")).toContain(
|
||||
failed?.status === "failed" ? failed.message : "missing failure",
|
||||
);
|
||||
// A directory-only secret rule still applies when the kind is unknown.
|
||||
const secretPattern = `${failed?.path ?? "missing"}/`;
|
||||
const secret = await importReferenceSource({
|
||||
root,
|
||||
caller: "native-entry-failure-detail-test",
|
||||
policy: { secretPatterns: [secretPattern] },
|
||||
});
|
||||
if (!secret.ok) throw new Error(secret.error.message);
|
||||
expect(
|
||||
secret.value.entries.some(({ path }) => path === failed?.path),
|
||||
).toBe(false);
|
||||
expect(secret.value.exclusions).toContainEqual({
|
||||
path: failed?.path,
|
||||
reason: "configured-secret",
|
||||
pattern: secretPattern,
|
||||
});
|
||||
} finally {
|
||||
await deepTree("remove");
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
|
||||
import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js";
|
||||
import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js";
|
||||
import { skillDestinations } from "../../../src/application/SetupSkill.js";
|
||||
import { supportedClients } from "../../../src/application/SupportedClients.js";
|
||||
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js";
|
||||
|
||||
/**
|
||||
* The registration command setup writes on the host running the tests. Windows
|
||||
* wraps it in `cmd.exe /d /c`, so the expected entry follows the host too.
|
||||
*/
|
||||
const command = npxRegistrationCommand(process.platform);
|
||||
|
||||
beforeEach(clearClientLocationEnvironment);
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
const workbuddyPath = (
|
||||
home: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
env: Parameters<typeof supportedClients>[2] = {},
|
||||
) =>
|
||||
supportedClients(home, platform, env).find(
|
||||
({ name }) => name === "workbuddy",
|
||||
);
|
||||
|
||||
describe("WorkBuddy configuration paths", () => {
|
||||
it("resolves the global mcp.json under the home root", () => {
|
||||
expect(workbuddyPath("/home/a")?.configPath).toBe(
|
||||
join("/home/a", ".workbuddy", "mcp.json"),
|
||||
);
|
||||
expect(workbuddyPath("/Users/a")?.markerPath).toBe(
|
||||
join("/Users/a", ".workbuddy"),
|
||||
);
|
||||
expect(workbuddyPath("C:\\Users\\a", "win32")?.configPath).toBe(
|
||||
join("C:\\Users\\a", ".workbuddy", "mcp.json"),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves the registration format as the plain mcpServers JSON dialect", () => {
|
||||
expect(workbuddyPath("/home/a")?.format).toBe("json");
|
||||
});
|
||||
|
||||
it("resolves the personal skills directory under the home root", () => {
|
||||
expect(
|
||||
skillDestinations("/home/a", ["workbuddy"]).map(({ path }) => path),
|
||||
).toEqual([
|
||||
join("/home/a", ".workbuddy", "skills", "reverse-engineer-anything"),
|
||||
]);
|
||||
expect(
|
||||
skillDestinations("C:\\Users\\a", ["workbuddy"], {}, "win32").map(
|
||||
({ path }) => path,
|
||||
),
|
||||
).toEqual([
|
||||
join("C:\\Users\\a", ".workbuddy", "skills", "reverse-engineer-anything"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkBuddy JSON registration", () => {
|
||||
const workbuddyClient = async () => {
|
||||
const home = await createTestTempDirectory("rea-workbuddy-");
|
||||
const client = workbuddyPath(home, process.platform);
|
||||
if (client?.markerPath === undefined)
|
||||
throw new Error("missing WorkBuddy client");
|
||||
await mkdir(client.markerPath, { recursive: true });
|
||||
return { home, client };
|
||||
};
|
||||
|
||||
it("upserts rea into mcpServers while preserving unrelated settings", async () => {
|
||||
const { home, client } = await workbuddyClient();
|
||||
await writeFile(
|
||||
client.configPath,
|
||||
`{
|
||||
"mcpServers": {
|
||||
"other": { "command": "other-server" }
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
expect(
|
||||
(await configureClientConfiguration(client, {}, command)).status,
|
||||
).toBe("configured");
|
||||
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
|
||||
expect(parsed.mcpServers.other).toEqual({ command: "other-server" });
|
||||
expect(parsed.mcpServers.rea).toEqual({
|
||||
command: command[0],
|
||||
args: command.slice(1),
|
||||
});
|
||||
|
||||
expect(await configureClientConfiguration(client, {}, command)).toEqual({
|
||||
status: "unchanged",
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await readClientRegistrationStatuses(home, undefined, {
|
||||
platform: process.platform,
|
||||
environment: {},
|
||||
})
|
||||
).find(({ client }) => client === "workbuddy"),
|
||||
).toMatchObject({ state: "aligned", command });
|
||||
});
|
||||
|
||||
it("creates the mcpServers table in an existing mcp.json without servers", async () => {
|
||||
const { client } = await workbuddyClient();
|
||||
await writeFile(
|
||||
client.configPath,
|
||||
JSON.stringify({ unrelated: { setting: true } }, null, 2),
|
||||
);
|
||||
expect(
|
||||
(await configureClientConfiguration(client, {}, command)).status,
|
||||
).toBe("configured");
|
||||
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
|
||||
expect(parsed.unrelated).toEqual({ setting: true });
|
||||
expect(parsed.mcpServers.rea).toEqual({
|
||||
command: command[0],
|
||||
args: command.slice(1),
|
||||
});
|
||||
});
|
||||
|
||||
it("registers into an empty mcp.json", async () => {
|
||||
const { client } = await workbuddyClient();
|
||||
await writeFile(client.configPath, "");
|
||||
expect(
|
||||
(await configureClientConfiguration(client, {}, command)).status,
|
||||
).toBe("configured");
|
||||
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
|
||||
expect(parsed.mcpServers.rea).toEqual({
|
||||
command: command[0],
|
||||
args: command.slice(1),
|
||||
});
|
||||
});
|
||||
|
||||
it("removes only the rea entry on uninstall", async () => {
|
||||
const { home, client } = await workbuddyClient();
|
||||
await writeFile(
|
||||
client.configPath,
|
||||
JSON.stringify(
|
||||
{ mcpServers: { other: { command: "other-server" } } },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
await configureClientConfiguration(client, {}, command);
|
||||
|
||||
expect((await systemUninstallHost(home).removeClient(client)).status).toBe(
|
||||
"removed",
|
||||
);
|
||||
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
|
||||
expect(parsed.mcpServers.other).toEqual({ command: "other-server" });
|
||||
expect(parsed.mcpServers.rea).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
} from "../../../src/application/SetupClientConfiguration.js";
|
||||
import { skillDestinations } from "../../../src/application/SetupSkill.js";
|
||||
import { supportedClients } from "../../../src/application/SupportedClients.js";
|
||||
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
|
||||
import {
|
||||
runUninstall,
|
||||
systemUninstallHost,
|
||||
} from "../../../src/application/Uninstall.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js";
|
||||
|
||||
@@ -257,6 +260,91 @@ describe("ZCode .agents fallback preservation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ZCode fallback uninstall", () => {
|
||||
const owned = { command: command[0], args: command.slice(1) };
|
||||
const writeFallback = async (home: string, servers: object) => {
|
||||
await mkdir(join(home, ".agents"), { recursive: true });
|
||||
const text = `${JSON.stringify({ ui: { keep: true }, mcpServers: servers }, null, 2)}\n`;
|
||||
await writeFile(fallbackPath(home), text);
|
||||
return text;
|
||||
};
|
||||
|
||||
it.each(["comment", "trailing-comma", "bom"])(
|
||||
"fails closed on strict fallback syntax: %s",
|
||||
async (mode) => {
|
||||
const { home } = await zcodeClient();
|
||||
const valid = await writeFallback(home, {
|
||||
rea: owned,
|
||||
other: { command: "other-server" },
|
||||
});
|
||||
const original =
|
||||
mode === "comment"
|
||||
? `// unsupported comment\n${valid}`
|
||||
: mode === "bom"
|
||||
? `\uFEFF${valid}`
|
||||
: valid.replace(/\}\s*$/u, ",}");
|
||||
await writeFile(fallbackPath(home), original);
|
||||
const result = await runUninstall(false, systemUninstallHost(home));
|
||||
expect(result.items).toContainEqual(
|
||||
expect.objectContaining({ name: "zcode", status: "failed" }),
|
||||
);
|
||||
expect(await readFile(fallbackPath(home), "utf8")).toBe(original);
|
||||
await expect(
|
||||
readFile(`${fallbackPath(home)}.rea.backup`),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
|
||||
it("removes the REA-owned fallback entry with the native registration", async () => {
|
||||
const { home, client } = await zcodeClient();
|
||||
await writeFile(
|
||||
client.configPath,
|
||||
JSON.stringify({ mcp: { servers: { rea: owned } } }),
|
||||
);
|
||||
const original = await writeFallback(home, {
|
||||
rea: owned,
|
||||
other: { command: "other-server" },
|
||||
});
|
||||
|
||||
const result = await runUninstall(false, systemUninstallHost(home));
|
||||
|
||||
expect(result.status).toBe("complete");
|
||||
expect(result.items.find(({ name }) => name === "zcode")?.status).toBe(
|
||||
"removed",
|
||||
);
|
||||
const native = JSON.parse(await readFile(client.configPath, "utf8"));
|
||||
expect(native.mcp.servers).toEqual({});
|
||||
const fallback = JSON.parse(await readFile(fallbackPath(home), "utf8"));
|
||||
expect(fallback).toEqual({
|
||||
ui: { keep: true },
|
||||
mcpServers: { other: { command: "other-server" } },
|
||||
});
|
||||
expect(await readFile(`${fallbackPath(home)}.rea.backup`, "utf8")).toBe(
|
||||
original,
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a fallback-only REA entry and keeps a foreign rea entry", async () => {
|
||||
for (const [servers, expected] of [
|
||||
[{ rea: owned }, {}],
|
||||
[
|
||||
{ rea: { command: "someone-else" } },
|
||||
{ rea: { command: "someone-else" } },
|
||||
],
|
||||
] as const) {
|
||||
const { home } = await zcodeClient();
|
||||
await writeFallback(home, servers);
|
||||
|
||||
expect(
|
||||
(await runUninstall(false, systemUninstallHost(home))).status,
|
||||
).toBe("complete");
|
||||
expect(
|
||||
JSON.parse(await readFile(fallbackPath(home), "utf8")).mcpServers,
|
||||
).toEqual(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ZCode fallback admission", () => {
|
||||
it.each([{ PORT: 123 }, { REA_MCP_INPUT_SCHEMA_PROFILE: "unknown" }])(
|
||||
"refuses malformed fallback REA environment %j before native writes",
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { functionDossierSchema } from "../../../src/domain/hopperValues.js";
|
||||
import type { RelationshipCoverage } from "../../../src/domain/relationshipCoverage.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
|
||||
const observe = (relationshipCoverage: RelationshipCoverage) => {
|
||||
const template = FUNCTION_COMPARISON_EXAMPLE.left;
|
||||
if (template.subject === null)
|
||||
throw new Error("Expected artifact-bound fixture");
|
||||
const dossier = functionDossierSchema.parse(template.normalized_result);
|
||||
return createEvidence(
|
||||
{
|
||||
path: template.subject.local_path,
|
||||
sha256: template.subject.digest.sha256,
|
||||
format: template.subject.format,
|
||||
},
|
||||
template.provider,
|
||||
{
|
||||
operation: "analyze_function",
|
||||
parameters: template.parameters,
|
||||
result: { ...dossier, callees: [], unresolved_calls: [] },
|
||||
confidence: template.confidence,
|
||||
authority: template.authority,
|
||||
relationshipCoverage,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
it("preserves directional gaps through advertised schemas and public path reconstruction", async () => {
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("Offline call paths must not launch a provider");
|
||||
});
|
||||
const server = createServer({ kind: "session", session });
|
||||
const client = new Client({ name: "function-call-directions", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const advertised = (await client.listTools()).tools.find(
|
||||
({ name }) => name === "build_call_path",
|
||||
);
|
||||
if (advertised === undefined)
|
||||
throw new Error("Missing build_call_path contract");
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
expect(ajv.validateSchema(advertised.inputSchema)).toBe(true);
|
||||
const validate = ajv.compile(advertised.inputSchema);
|
||||
for (const { directions, status, exhaustive } of [
|
||||
{
|
||||
directions: ["callers"] as ["callers"],
|
||||
status: "not_found",
|
||||
exhaustive: true,
|
||||
},
|
||||
{
|
||||
directions: ["callees"] as ["callees"],
|
||||
status: "unknown",
|
||||
exhaustive: false,
|
||||
},
|
||||
{
|
||||
directions: ["callers", "callees"] as ["callers", "callees"],
|
||||
status: "unknown",
|
||||
exhaustive: false,
|
||||
},
|
||||
]) {
|
||||
const limitation =
|
||||
"An observed call reference is absent from the inventory.";
|
||||
const coverage: RelationshipCoverage = {
|
||||
status: "partial",
|
||||
limitations: [limitation],
|
||||
limited_directions: directions,
|
||||
};
|
||||
const observation = observe(coverage);
|
||||
expect(session.recordEvidence(observation).ok).toBe(true);
|
||||
const input = {
|
||||
functions: [observation],
|
||||
start: { address: "0x1000" },
|
||||
goal: { address: "0x9999" },
|
||||
};
|
||||
expect(validate(input), JSON.stringify(validate.errors)).toBe(true);
|
||||
for (const limited_directions of [
|
||||
[],
|
||||
["callers", "callers"],
|
||||
["callees", "callees"],
|
||||
["callers", "callees", "callers"],
|
||||
["incoming"],
|
||||
])
|
||||
expect(
|
||||
validate({
|
||||
...input,
|
||||
functions: [
|
||||
{
|
||||
...observation,
|
||||
relationship_coverage: {
|
||||
...coverage,
|
||||
limited_directions,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(false);
|
||||
const result = await client.callTool({
|
||||
name: "build_call_path",
|
||||
arguments: input,
|
||||
});
|
||||
if (result.isError) throw new Error(JSON.stringify(result));
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
normalized_result: {
|
||||
status,
|
||||
search_scope: { exhaustive },
|
||||
limitations: expect.arrayContaining([limitation]),
|
||||
},
|
||||
});
|
||||
const comparison = await client.callTool({
|
||||
name: "compare_functions",
|
||||
arguments: { left: observation, right: observation },
|
||||
});
|
||||
if (comparison.isError) throw new Error(JSON.stringify(comparison));
|
||||
expect(comparison.structuredContent).toMatchObject({
|
||||
normalized_result: {
|
||||
dimensions: expect.arrayContaining([
|
||||
expect.objectContaining({ dimension: "calls", status: "unknown" }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
// Each scenario owns its Evidence and residual-unknown ledger.
|
||||
const closed = await session.close();
|
||||
if (!closed.ok) throw closed.error;
|
||||
}
|
||||
} finally {
|
||||
await Promise.allSettled([session.close(), client.close(), server.close()]);
|
||||
}
|
||||
});
|
||||
@@ -15,9 +15,17 @@ import {
|
||||
type GhidraFunctionOperation,
|
||||
} from "../../../src/ghidra/GhidraFunctionValues.js";
|
||||
|
||||
it.each([true, false])(
|
||||
"compares observed empty Ghidra facets while preserving pseudocode availability (%s)",
|
||||
async (pseudocodeAvailable) => {
|
||||
it.each([
|
||||
{ pseudocodeAvailable: true, reportsCoverage: true },
|
||||
{ pseudocodeAvailable: false, reportsCoverage: true },
|
||||
{ pseudocodeAvailable: true, reportsCoverage: false },
|
||||
{ pseudocodeAvailable: false, reportsCoverage: false },
|
||||
])(
|
||||
"compares observed empty Ghidra facets with pseudocode=$pseudocodeAvailable and coverage=$reportsCoverage",
|
||||
async ({ pseudocodeAvailable, reportsCoverage }) => {
|
||||
const coverage = reportsCoverage
|
||||
? { status: "complete" as const }
|
||||
: undefined;
|
||||
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
Object.assign(dossier, {
|
||||
comments: [],
|
||||
@@ -47,7 +55,13 @@ it.each([true, false])(
|
||||
if (operation !== "analyze_function")
|
||||
throw new Error(`Unexpected operation: ${operation}`);
|
||||
return Promise.resolve(
|
||||
ok({ value: jsonValueSchema.parse(dossier), limitations: [] }),
|
||||
ok({
|
||||
value: jsonValueSchema.parse(dossier),
|
||||
limitations: [],
|
||||
...(coverage === undefined
|
||||
? {}
|
||||
: { relationship_coverage: coverage }),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -61,9 +75,7 @@ it.each([true, false])(
|
||||
harness.session,
|
||||
analyzed.structuredContent,
|
||||
);
|
||||
expect(evidence.relationship_coverage).toMatchObject({
|
||||
status: "complete",
|
||||
});
|
||||
expect(evidence.relationship_coverage).toEqual(coverage ?? null);
|
||||
expect(evidence.normalized_result).toEqual(dossier);
|
||||
const reply = await harness.mcp.callTool({
|
||||
name: "compare_functions",
|
||||
@@ -86,7 +98,7 @@ it.each([true, false])(
|
||||
pseudocode: pseudocodeAvailable ? "unchanged" : "unknown",
|
||||
assembly: "unchanged",
|
||||
comments: "unchanged",
|
||||
calls: "unchanged",
|
||||
calls: reportsCoverage ? "unchanged" : "unknown",
|
||||
references: "unchanged",
|
||||
strings_names: "unchanged",
|
||||
cfg: "unchanged",
|
||||
@@ -96,9 +108,7 @@ it.each([true, false])(
|
||||
evidence.parameters,
|
||||
);
|
||||
if (!replay.ok) throw replay.error;
|
||||
expect(replay.value.relationshipCoverage).toEqual(
|
||||
evidence.relationship_coverage,
|
||||
);
|
||||
expect(replay.value.relationshipCoverage).toEqual(coverage);
|
||||
expect(harness.calls).toEqual(["analyze_function"]);
|
||||
} finally {
|
||||
await harness.close();
|
||||
|
||||
@@ -106,6 +106,8 @@ export const ghidraInstallationHost = (): GhidraInstallationHost => ({
|
||||
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/usr/lib/jvm/jdk-21",
|
||||
|
||||
@@ -102,6 +102,8 @@ const installationHost: GhidraInstallationHost = {
|
||||
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
version: "21.0.11",
|
||||
major: 21,
|
||||
home: "/fixture/jdk-21",
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, vi } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import {
|
||||
processTest,
|
||||
type TestProcesses,
|
||||
} from "../../support/process/processFixture.js";
|
||||
|
||||
const script = `
|
||||
import { PlaywrightElectronActiveProvider } from "./dist/browser/PlaywrightElectronActiveProvider.js";
|
||||
import { electronActiveObservationInputSchema } from "./dist/domain/javascript/electronActiveObservation.js";
|
||||
|
||||
const [scenario, executable, root] = JSON.parse(process.argv[1]);
|
||||
let observedRejections = 0;
|
||||
if (scenario === "oneFailureWithManySuccesses" || scenario === "externalListenerSeesUnrelatedOnce")
|
||||
process.on("unhandledRejection", () => { observedRejections += 1; });
|
||||
const orphanStack = (message, file) =>
|
||||
message + "\\n at waitForLine (/app/node_modules/playwright-core/lib/server/" + file + ":247:21)";
|
||||
const playwrightError = (message, file = "electron/electron.js") => {
|
||||
const error = new Error(message);
|
||||
error.stack = "Error: " + orphanStack(message, file);
|
||||
return error;
|
||||
};
|
||||
const rejectLater = (error, ms) => setTimeout(() => void Promise.reject(error), ms);
|
||||
const fakeApplication = { process: () => ({ pid: undefined }), close: async () => undefined };
|
||||
const launchFailure = (message) =>
|
||||
new Error("electron.launch: " + message + "\\nCall log:\\n - <launching>");
|
||||
const after = (ms, action) => new Promise((resolve, reject) => setTimeout(() => {
|
||||
try { resolve(action()); } catch (error) { reject(error); }
|
||||
}, ms));
|
||||
const orphans = (message) => {
|
||||
for (let index = 0; index < 3; index += 1) void Promise.reject(playwrightError(message));
|
||||
};
|
||||
|
||||
let during = -1;
|
||||
const launches = {
|
||||
realLaunch: undefined,
|
||||
realWithUnrelatedPlaywrightRejection: async (options) => {
|
||||
void Promise.reject(playwrightError("unrelated playwright failure", "page.js"));
|
||||
return (await import("playwright-core"))._electron.launch(options);
|
||||
},
|
||||
failureWithOrphans: async () => {
|
||||
during = process.listenerCount("unhandledRejection");
|
||||
orphans("Process failed to launch!");
|
||||
await after(10, () => { throw launchFailure("Process failed to launch!"); });
|
||||
},
|
||||
failureInOneTick: async () => {
|
||||
orphans("Process failed to launch!");
|
||||
throw launchFailure("Process failed to launch!");
|
||||
},
|
||||
failureWithUnrelatedPlaywrightRejection: async () => {
|
||||
orphans("Process failed to launch!");
|
||||
void Promise.reject(playwrightError("Target page, context or browser has been closed", "page.js"));
|
||||
await after(10, () => { throw launchFailure("Process failed to launch!"); });
|
||||
},
|
||||
externalListenerSeesUnrelatedOnce: async () => {
|
||||
void Promise.reject(playwrightError("unrelated playwright failure", "page.js"));
|
||||
await after(10, () => { throw launchFailure("Process failed to launch!"); });
|
||||
},
|
||||
pendingWithUnrelatedRejection: () => {
|
||||
setInterval(() => {}, 1000);
|
||||
rejectLater(new Error("unrelated application failure"), 20);
|
||||
return new Promise(() => {});
|
||||
},
|
||||
success: async () => {
|
||||
during = process.listenerCount("unhandledRejection");
|
||||
return fakeApplication;
|
||||
},
|
||||
successWithPlaywrightRejection: async () => {
|
||||
void Promise.reject(playwrightError("stray playwright failure", "page.js"));
|
||||
await after(10, () => fakeApplication);
|
||||
return fakeApplication;
|
||||
},
|
||||
};
|
||||
|
||||
const input = (executablePath) => ({
|
||||
...electronActiveObservationInputSchema.parse({
|
||||
executable_path: executablePath,
|
||||
application_path: root,
|
||||
}),
|
||||
application_root: root,
|
||||
});
|
||||
const capture = (launch, executablePath = executable) =>
|
||||
new PlaywrightElectronActiveProvider({}, launch).capture(input(executablePath));
|
||||
|
||||
const before = process.listenerCount("unhandledRejection");
|
||||
let results;
|
||||
if (scenario === "concurrentFailures" || scenario === "failureThenSuccess" || scenario === "successThenFailure" || scenario === "oneFailureWithManySuccesses") {
|
||||
const failing = (message, orphanAt, settleAt) => async () => {
|
||||
during = Math.max(during, process.listenerCount("unhandledRejection"));
|
||||
await after(orphanAt, () => orphans(message));
|
||||
await after(settleAt - orphanAt, () => { throw launchFailure(message); });
|
||||
};
|
||||
const succeeding = (settleAt) => () => {
|
||||
during = Math.max(during, process.listenerCount("unhandledRejection"));
|
||||
return after(settleAt, () => fakeApplication);
|
||||
};
|
||||
const pair = {
|
||||
concurrentFailures: [failing("alpha failed", 10, 30), failing("beta failed", 20, 50)],
|
||||
failureThenSuccess: [failing("alpha failed", 10, 20), succeeding(40)],
|
||||
successThenFailure: [failing("alpha failed", 20, 40), succeeding(30)],
|
||||
oneFailureWithManySuccesses: [failing("alpha failed", 10, 80), ...[20, 30, 40, 50].map(succeeding)],
|
||||
}[scenario];
|
||||
results = await Promise.all(pair.map((launch) => capture(launch)));
|
||||
} else {
|
||||
results = [await capture(launches[scenario] ?? undefined)];
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
console.log(JSON.stringify({
|
||||
before,
|
||||
during,
|
||||
after: process.listenerCount("unhandledRejection"),
|
||||
observedRejections,
|
||||
reasons: results.map((result) => result.ok ? null : result.error.reason ?? null),
|
||||
ok: results.map((result) => result.ok),
|
||||
causeReasons: results.map((result) => result.ok ? null : result.error.cause?.reason ?? null),
|
||||
cleanupResources: results.map((result) => result.ok ? null : result.error.cleanupResources ?? null),
|
||||
causes: results.map((result) => result.ok ? null : String(result.error.cause?.message).split("\\n")[0]),
|
||||
}));
|
||||
`;
|
||||
|
||||
// A cold macOS Swift toolchain may spend up to 120 seconds building the
|
||||
// ownership reader before any child launch reaches its assertion.
|
||||
vi.setConfig({ testTimeout: 180_000 });
|
||||
|
||||
type Outcome = {
|
||||
readonly before: number;
|
||||
readonly during: number;
|
||||
readonly after: number;
|
||||
readonly ok: readonly boolean[];
|
||||
readonly causes: readonly (string | null)[];
|
||||
readonly observedRejections: number;
|
||||
readonly reasons: readonly (string | null)[];
|
||||
readonly causeReasons: readonly (string | null)[];
|
||||
readonly cleanupResources: readonly (readonly string[] | null)[];
|
||||
};
|
||||
|
||||
const run = async (
|
||||
processes: TestProcesses,
|
||||
scenario: string,
|
||||
executable = "/not/launched",
|
||||
root = "/",
|
||||
) =>
|
||||
processes.run(
|
||||
process.execPath,
|
||||
[
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
script,
|
||||
JSON.stringify([scenario, executable, root]),
|
||||
],
|
||||
{ timeoutMs: 150_000 },
|
||||
);
|
||||
|
||||
const outcome = (result: { readonly stdout: string }): Outcome =>
|
||||
JSON.parse(result.stdout.trim().split("\n").at(-1) ?? "") as Outcome;
|
||||
|
||||
const earlyExit = async () => {
|
||||
const root = await createTestTempDirectory("rea-launch-rejections-");
|
||||
const executable = join(root, "exits-immediately");
|
||||
await mkdir(join(root, "app"));
|
||||
await writeFile(executable, "#!/bin/sh\nexit 0\n");
|
||||
await chmod(executable, 0o755);
|
||||
return { executable, root: join(root, "app") };
|
||||
};
|
||||
|
||||
processTest(
|
||||
"absorbs the orphaned rejections of a failed launch and removes its listener",
|
||||
async ({ processes }) => {
|
||||
const result = await run(processes, "failureWithOrphans");
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(outcome(result)).toMatchObject({
|
||||
during: outcome(result).before + 1,
|
||||
after: outcome(result).before,
|
||||
ok: [false],
|
||||
causes: ["electron.launch: Process failed to launch!"],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
processTest(
|
||||
"absorbs orphans raised in the same tick as the launch failure",
|
||||
async ({ processes }) => {
|
||||
const result = await run(processes, "failureInOneTick");
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(outcome(result)).toMatchObject({ ok: [false] });
|
||||
},
|
||||
);
|
||||
|
||||
processTest.skipIf(process.platform === "win32")(
|
||||
"absorbs the orphans of a real Playwright launch that exits at once",
|
||||
async ({ processes }) => {
|
||||
const { executable, root } = await earlyExit();
|
||||
const result = await run(processes, "realLaunch", executable, root);
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(outcome(result)).toMatchObject({
|
||||
ok: [false],
|
||||
causes: ["electron.launch: Process failed to launch!"],
|
||||
});
|
||||
expect(outcome(result).after).toBe(outcome(result).before);
|
||||
},
|
||||
);
|
||||
|
||||
processTest.skipIf(process.platform === "win32")(
|
||||
"still fails on an unrelated Playwright rejection during a real failing launch",
|
||||
async ({ processes }) => {
|
||||
const { executable, root } = await earlyExit();
|
||||
const result = await run(
|
||||
processes,
|
||||
"realWithUnrelatedPlaywrightRejection",
|
||||
executable,
|
||||
root,
|
||||
);
|
||||
expect(result.exitCode, result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain("unrelated playwright failure");
|
||||
expect(result.stdout).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
processTest(
|
||||
"does not replay an unrelated orphan to a listener that already received it",
|
||||
async ({ processes }) => {
|
||||
const result = await run(processes, "externalListenerSeesUnrelatedOnce");
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(outcome(result)).toMatchObject({
|
||||
observedRejections: 1,
|
||||
ok: [false],
|
||||
causes: ["electron.launch: Process failed to launch!"],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
processTest(
|
||||
"still fails on an unrelated Playwright rejection beside a failing launch's own orphans",
|
||||
async ({ processes }) => {
|
||||
const result = await run(
|
||||
processes,
|
||||
"failureWithUnrelatedPlaywrightRejection",
|
||||
);
|
||||
expect(result.exitCode, result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain(
|
||||
"Target page, context or browser has been closed",
|
||||
);
|
||||
expect(result.stdout).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
processTest(
|
||||
"still fails at once on an unrelated rejection while a launch is pending",
|
||||
async ({ processes }) => {
|
||||
const result = await run(processes, "pendingWithUnrelatedRejection");
|
||||
expect(result.exitCode, result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain("unrelated application failure");
|
||||
expect(result.stdout).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
processTest(
|
||||
"removes its listener after a successful launch",
|
||||
async ({ processes }) => {
|
||||
const result = await run(processes, "success");
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(outcome(result)).toMatchObject({
|
||||
during: outcome(result).before + 1,
|
||||
after: outcome(result).before,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
processTest(
|
||||
"re-raises a Playwright rejection when the launch succeeds",
|
||||
async ({ processes }) => {
|
||||
const result = await run(processes, "successWithPlaywrightRejection");
|
||||
expect(result.exitCode, result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain("stray playwright failure");
|
||||
expect(result.stdout).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
processTest.for([
|
||||
"concurrentFailures",
|
||||
"failureThenSuccess",
|
||||
"successThenFailure",
|
||||
])(
|
||||
"keeps overlapping launches from re-raising each other's orphans (%s)",
|
||||
async (scenario, { processes }) => {
|
||||
const result = await run(processes, scenario);
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(outcome(result).after).toBe(outcome(result).before);
|
||||
},
|
||||
);
|
||||
|
||||
processTest(
|
||||
"holds each orphan once across many overlapping successful launches",
|
||||
async ({ processes }) => {
|
||||
const result = await run(processes, "oneFailureWithManySuccesses");
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const observed = outcome(result);
|
||||
expect(observed.during).toBe(observed.before + 1);
|
||||
expect(observed.after).toBe(observed.before);
|
||||
expect(observed.observedRejections).toBe(3);
|
||||
expect(observed.ok).toEqual([false, false, false, false, false]);
|
||||
expect(observed.causes[0]).toBe("electron.launch: alpha failed");
|
||||
expect(observed.reasons).toEqual([
|
||||
null,
|
||||
"cleanup_failed",
|
||||
"cleanup_failed",
|
||||
"cleanup_failed",
|
||||
"cleanup_failed",
|
||||
]);
|
||||
expect(observed.causeReasons).toEqual([
|
||||
null,
|
||||
...Array.from({ length: 4 }, () => "process_ownership_unavailable"),
|
||||
]);
|
||||
expect(observed.cleanupResources).toEqual([
|
||||
[],
|
||||
...Array.from({ length: 4 }, () => ["owned_process_group"]),
|
||||
]);
|
||||
},
|
||||
);
|
||||
@@ -50,9 +50,16 @@ if (mode === "env-failure" || (probe === "-vmargs" && mode === "vm-failure")) {
|
||||
process.stderr.write("failed " + probe + "\\n");
|
||||
process.exitCode = 3;
|
||||
} else if (probe === "-envvars") {
|
||||
process.stdout.write(mode === "env-malformed" ? "invalid environment\\n" : "GHIDRA_MAXMEM=1G\\n");
|
||||
process.stdout.write(mode === "env-malformed" ? "invalid environment\\n" : mode.startsWith("env-injection:") ? mode.slice("env-injection:".length) + "\\n" : "GHIDRA_MAXMEM=1G\\n");
|
||||
} else {
|
||||
process.stdout.write(mode === "vm-malformed" ? "invalid JVM argument\\n" : "-Dfile.encoding=UTF8\\n");
|
||||
const vmArguments = mode === "vm-malformed"
|
||||
? "invalid JVM argument\\n"
|
||||
: mode === "os-arch-override"
|
||||
? "-Dos.arch=x86_64\\n"
|
||||
: mode === "os-name-override"
|
||||
? "-Dos.name=Windows\\n"
|
||||
: "-Dfile.encoding=UTF8\\n";
|
||||
process.stdout.write(vmArguments);
|
||||
}
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
@@ -215,6 +222,57 @@ describe.skipIf(process.platform === "win32")(
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["os-arch-override", "os-name-override"])(
|
||||
"rejects LaunchSupport %s before starting analysis",
|
||||
async (mode) => {
|
||||
const { session, launcher, analysisArguments } = await fixture(mode);
|
||||
const launched = await launcher.launch(session);
|
||||
expect(launched.ok).toBe(false);
|
||||
expect(analysisArguments).toEqual([]);
|
||||
if (launched.ok)
|
||||
throw new Error("Expected a JVM platform override failure");
|
||||
expect(launched.error.cause).toMatchObject({
|
||||
message: expect.stringContaining(
|
||||
"may not override os.arch or os.name",
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["JAVA_TOOL_OPTIONS", "-Dos.arch=x86_64"],
|
||||
["JAVA_TOOL_OPTIONS", '"-Dos.arch=x86_64"'],
|
||||
["_JAVA_OPTIONS", "'-Dos.name=Windows'"],
|
||||
["JDK_JAVA_OPTIONS", '-D"os.name"=Windows'],
|
||||
["JDK_JAVA_OPTIONS", "-D'os.arch'=x86_64"],
|
||||
])(
|
||||
"rejects helper-environment platform injection from %s=%s before analysis",
|
||||
async (key, value) => {
|
||||
const { session, launcher, analysisArguments } = await fixture(
|
||||
`env-injection:${key}=${value}`,
|
||||
);
|
||||
const launched = await launcher.launch(session);
|
||||
expect(launched.ok).toBe(false);
|
||||
expect(analysisArguments).toEqual([]);
|
||||
if (launched.ok)
|
||||
throw new Error("Expected a JVM platform override failure");
|
||||
expect(launched.error.cause).toMatchObject({
|
||||
message: expect.stringContaining(
|
||||
"may not override os.arch or os.name",
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("admits a quoted non-platform option without mistaking its value for a platform argument", async () => {
|
||||
const { session, launcher, analysisArguments } = await fixture(
|
||||
'env-injection:JAVA_TOOL_OPTIONS=-Ddescription="text -Dos.arch=x86_64"',
|
||||
);
|
||||
const launched = await launcher.launch(session);
|
||||
if (!launched.ok) throw launched.error;
|
||||
expect(analysisArguments).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("bounds retained UTF-8 warnings and reports original byte counts", async () => {
|
||||
const { session, launcher } = await fixture("large");
|
||||
const launched = await launcher.launch(session);
|
||||
@@ -290,6 +348,13 @@ describe.skipIf(process.platform === "win32")(
|
||||
"application.version=12.1.4\napplication.java.min=21\n",
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
osArch: process.arch === "arm64" ? "aarch64" : "amd64",
|
||||
osName:
|
||||
process.platform === "darwin"
|
||||
? "Mac OS X"
|
||||
: process.platform === "win32"
|
||||
? "Windows 11"
|
||||
: "Linux",
|
||||
version: "21.0.12",
|
||||
major: 21,
|
||||
home: "/fixture/jdk",
|
||||
|
||||
@@ -524,6 +524,13 @@ describe("Ghidra Windows launcher runtime", () => {
|
||||
),
|
||||
javaHome: "C:\\Java\\jdk-21",
|
||||
bridgeScriptPath: join(parent, "ReaGhidraBridge.java"),
|
||||
// The preflight validates the inspected JVM's LaunchSupport output;
|
||||
// these fixtures assert batch-spawn behavior without a working JDK.
|
||||
javaLaunch: async () => ({
|
||||
command: "C:\\Java\\jdk-21\\bin\\java.exe",
|
||||
arguments: [],
|
||||
environment: {},
|
||||
}),
|
||||
spawnProcess,
|
||||
});
|
||||
const launched = await launcher.launch({
|
||||
@@ -569,6 +576,13 @@ describe("Ghidra Windows launcher runtime", () => {
|
||||
platform: "win32",
|
||||
javaHome,
|
||||
bridgeScriptPath: "/package/bridge/ReaGhidraBridge.java",
|
||||
// The preflight validates the inspected JVM's LaunchSupport output;
|
||||
// this fixture captures the batch launcher without a working JDK.
|
||||
javaLaunch: async () => ({
|
||||
command: `${javaHome}\\bin\\java.exe`,
|
||||
arguments: [],
|
||||
environment: {},
|
||||
}),
|
||||
});
|
||||
const launched = await launcher.launch({
|
||||
runtimeRoot,
|
||||
|
||||
@@ -12,6 +12,8 @@ import ghidra.program.model.listing.FlowOverride;
|
||||
import ghidra.program.model.listing.Function;
|
||||
import ghidra.program.model.listing.Instruction;
|
||||
import ghidra.program.model.symbol.SourceType;
|
||||
import ghidra.program.model.symbol.Reference;
|
||||
import ghidra.program.model.symbol.RefType;
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
@@ -20,9 +22,12 @@ import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.ArrayList;
|
||||
|
||||
/** Injects reported analysis flags into imported ELF/Mach-O functions; never executes target code. */
|
||||
public class ReaNoReturnProbe extends GhidraScript {
|
||||
private record MemoryCallReference(Address source, Address target, RefType type,
|
||||
SourceType sourceType, int operandIndex, boolean primary) {}
|
||||
private final JsonArray checks = new JsonArray();
|
||||
private final Map<Function, Address> originalEnds = new LinkedHashMap<>();
|
||||
private final Map<Function, String> originalSignatures = new LinkedHashMap<>();
|
||||
@@ -85,6 +90,114 @@ public class ReaNoReturnProbe extends GhidraScript {
|
||||
return instance;
|
||||
}
|
||||
|
||||
private void knownInteriorCall(GhidraScript bridge) throws Exception {
|
||||
Function caller = function("rea_tls_chain");
|
||||
Function target = function("rea_tls");
|
||||
Address interior = target.getEntryPoint().add(1);
|
||||
require("interior_target_is_owned", currentProgram.getFunctionManager().getFunctionAt(interior) == null &&
|
||||
target.equals(currentProgram.getFunctionManager().getFunctionContaining(interior)));
|
||||
Instruction call = null;
|
||||
for (Instruction instruction : currentProgram.getListing().getInstructions(caller.getBody(), true)) {
|
||||
if (instruction.getFlowType().isCall()) { call = instruction; break; }
|
||||
}
|
||||
if (call == null) throw new IllegalStateException("Missing real call instruction");
|
||||
var references = currentProgram.getReferenceManager();
|
||||
List<Reference> originals = new ArrayList<>();
|
||||
List<MemoryCallReference> saved = new ArrayList<>();
|
||||
for (Reference reference : call.getReferencesFrom()) {
|
||||
if (!reference.getReferenceType().isCall()) continue;
|
||||
originals.add(reference);
|
||||
saved.add(new MemoryCallReference(reference.getFromAddress(), reference.getToAddress(),
|
||||
reference.getReferenceType(), reference.getSource(), reference.getOperandIndex(), reference.isPrimary()));
|
||||
}
|
||||
require("original_call_reference_present", !originals.isEmpty());
|
||||
Reference changed = null;
|
||||
try {
|
||||
for (Reference reference : originals) references.delete(reference);
|
||||
changed = references.addMemoryReference(call.getAddress(), interior,
|
||||
RefType.UNCONDITIONAL_CALL, SourceType.USER_DEFINED, 0);
|
||||
require("interior_target_omitted_by_callee_getter", !caller.getCalledFunctions(monitor).contains(target));
|
||||
JsonObject query = new JsonObject();
|
||||
query.addProperty("document", currentProgram.getName());
|
||||
query.addProperty("procedure", caller.getName());
|
||||
query.addProperty("direction", "outgoing");
|
||||
Method read = bridge.getClass().getDeclaredMethod("procedureReferences", JsonObject.class);
|
||||
read.setAccessible(true);
|
||||
JsonObject output = (JsonObject) read.invoke(bridge, query);
|
||||
require("interior_reference_keeps_known_procedure", output.getAsJsonArray("references").asList().stream().anyMatch(
|
||||
edge -> !edge.getAsJsonObject().get("target_procedure").isJsonNull() &&
|
||||
edge.getAsJsonObject().getAsJsonObject("target_procedure").get("name").getAsString().equals(target.getName())));
|
||||
require("known_reference_is_not_also_unresolved", output.getAsJsonArray("unresolved_calls").isEmpty());
|
||||
query.remove("direction");
|
||||
Method callees = bridge.getClass().getDeclaredMethod("procedureCalls", JsonObject.class, boolean.class);
|
||||
callees.setAccessible(true);
|
||||
JsonObject inventory = (JsonObject) callees.invoke(bridge, query, false);
|
||||
require("omitted_callee_inventory_stays_partial", inventory.getAsJsonObject("relationship_coverage").get("status").getAsString().equals("partial"));
|
||||
require("omitted_callee_direction_is_explicit",
|
||||
inventory.getAsJsonObject("relationship_coverage").getAsJsonArray("limited_directions").toString().equals("[\"callees\"]"));
|
||||
require("interior_caller_omitted_by_entry_getter", !target.getCallingFunctions(monitor).contains(caller));
|
||||
query.addProperty("procedure", target.getName());
|
||||
JsonObject incoming = (JsonObject) callees.invoke(bridge, query, true);
|
||||
require("off_entry_caller_inventory_stays_partial",
|
||||
incoming.getAsJsonObject("relationship_coverage").get("status").getAsString().equals("partial"));
|
||||
require("off_entry_caller_direction_is_explicit",
|
||||
incoming.getAsJsonObject("relationship_coverage").getAsJsonArray("limited_directions").toString().equals("[\"callers\"]"));
|
||||
JsonObject dossierQuery = new JsonObject();
|
||||
dossierQuery.addProperty("procedure", target.getName());
|
||||
Method analyze = bridge.getClass().getDeclaredMethod("analyzeFunctionObservation", JsonObject.class);
|
||||
analyze.setAccessible(true);
|
||||
Object observation = analyze.invoke(bridge, dossierQuery);
|
||||
Method coverage = observation.getClass().getDeclaredMethod("relationshipCoverage");
|
||||
coverage.setAccessible(true);
|
||||
require("dossier_preserves_caller_only_gap",
|
||||
((JsonObject) coverage.invoke(observation)).getAsJsonArray("limited_directions").toString().equals("[\"callers\"]"));
|
||||
} finally {
|
||||
if (changed != null) references.delete(changed);
|
||||
for (MemoryCallReference reference : saved) {
|
||||
Reference restored = references.addMemoryReference(reference.source(), reference.target(),
|
||||
reference.type(), reference.sourceType(), reference.operandIndex());
|
||||
references.setPrimary(restored, reference.primary());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void nonCallInstructionReference(GhidraScript bridge) throws Exception {
|
||||
Function caller = function("rea_tls_chain");
|
||||
Function target = function("rea_memcpy");
|
||||
Instruction source = null;
|
||||
for (Instruction instruction : currentProgram.getListing().getInstructions(caller.getBody(), true)) {
|
||||
if (!instruction.getFlowType().isCall()) { source = instruction; break; }
|
||||
}
|
||||
if (source == null) throw new IllegalStateException("Missing non-call instruction");
|
||||
var references = currentProgram.getReferenceManager();
|
||||
Reference added = references.addMemoryReference(source.getAddress(), target.getEntryPoint().add(1),
|
||||
RefType.UNCONDITIONAL_CALL, SourceType.USER_DEFINED, -1);
|
||||
try {
|
||||
require("non_call_reference_target_omitted", !caller.getCalledFunctions(monitor).contains(target));
|
||||
JsonObject query = new JsonObject();
|
||||
query.addProperty("document", currentProgram.getName());
|
||||
query.addProperty("procedure", caller.getName());
|
||||
Method read = bridge.getClass().getDeclaredMethod("procedureCalls", JsonObject.class, boolean.class);
|
||||
read.setAccessible(true);
|
||||
JsonObject output = (JsonObject) read.invoke(bridge, query, false);
|
||||
require("non_call_instruction_reference_stays_partial",
|
||||
output.getAsJsonObject("relationship_coverage").get("status").getAsString().equals("partial"));
|
||||
query.addProperty("direction", "outgoing");
|
||||
Method edges = bridge.getClass().getDeclaredMethod("procedureReferences", JsonObject.class);
|
||||
edges.setAccessible(true);
|
||||
JsonObject known = (JsonObject) edges.invoke(bridge, query);
|
||||
require("non_call_known_reference_is_not_unresolved", known.getAsJsonArray("unresolved_calls").isEmpty());
|
||||
references.delete(added);
|
||||
Address unknown = currentProgram.getMemory().getMaxAddress().add(0x1000);
|
||||
added = references.addMemoryReference(source.getAddress(), unknown,
|
||||
RefType.UNCONDITIONAL_CALL, SourceType.USER_DEFINED, -1);
|
||||
JsonObject unresolved = (JsonObject) edges.invoke(bridge, query);
|
||||
require("non_call_unknown_reference_keeps_site", unresolved.getAsJsonArray("unresolved_calls").size() == 1);
|
||||
} finally {
|
||||
references.delete(added);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
String[] args = getScriptArgs();
|
||||
@@ -164,6 +277,8 @@ public class ReaNoReturnProbe extends GhidraScript {
|
||||
Object output = pseudocode.invoke(bridge, query);
|
||||
require("recovered_pseudocode", output instanceof JsonObject value &&
|
||||
value.get("value").getAsString().contains("rea_after_call"));
|
||||
nonCallInstructionReference(bridge);
|
||||
knownInteriorCall(bridge);
|
||||
} finally {
|
||||
((ghidra.app.decompiler.DecompInterface) field.get(bridge)).dispose();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user