Compare commits

...
Author SHA1 Message Date
William BlairGitHubmorlutoKaoru0822factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
41a2e84647 docs(installation): limit REA to one project or agent profile
CI / Classify changes (push) Canceled after 0s
Release / Update release proposal (push) Canceled after 0s
Release / Release reviewed main merge (push) Canceled after 0s
Release / Release Please (push) Canceled after 0s
CI / Static checks (push) Canceled after 0s
CI / Source tests (push) Canceled after 0s
CI / Test shard (${{ fromJSON(needs.changes.outputs.tier == 'fast' && '["1/5","2/5","3/5","4/5","5/5"]' || '["1/6","2/6","3/6","4/6","5/6","6/6"]') }}) (push) Canceled after 0s
CI / Reverse tools coverage and mutation (push) Canceled after 0s
CI / Build package (push) Canceled after 0s
CI / sqlite-runtime (push) Canceled after 0s
CI / Portable tests (push) Canceled after 0s
CI / Source-built conformance fixtures (push) Canceled after 0s
CI / Build bundled Windows x64 native controls (push) Canceled after 0s
CI / Native package baseline (${{ matrix.platform }} ${{ matrix.architecture }}) (push) Canceled after 0s
CI / Real Apple artifact CLI/MCP E2E (push) Canceled after 0s
CI / Reconstruction readiness (push) Canceled after 0s
CI / Windows capability and package lane (push) Canceled after 0s
CI / Docs Generation (push) Canceled after 0s
CI / browser (push) Canceled after 0s
CI / binary_layout (push) Canceled after 0s
CI / evm (push) Canceled after 0s
CI / javascript_recovery (push) Canceled after 0s
CI / recorded_crash (push) Canceled after 0s
CI / web_network (push) Canceled after 0s
CI / web_runtime (push) Canceled after 0s
CI / web_source_map (push) Canceled after 0s
CI / nativeaot (push) Canceled after 0s
CI / website (push) Canceled after 0s
CI / CI required (push) Canceled after 0s
Release / Qualify exact release revision (push) Canceled after 0s
Release / Publish to npm (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
Document existing Claude Code profile and per-project installation workflows, including scoped maintenance, backups, caller metadata and Windows commands. Clarify authentication storage and the filesystem/network restrictions required for enforced separation.

Validated 68 boundary tests, docs checks, formatting, typecheck/lint and two real REA setup dry-runs with zero applied actions or profile writes. Native Claude sessions, PowerShell execution and enforcement policies were not tested. Setup automation remains separate in #2187.

Co-authored-by: William Blair <60581672+williamblair333@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-10-14 08:45:50 +08:00
6a7f70fd54 test(windows): verify retained inspection ownership after rename (#2902)
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
2026-10-13 17:39:38 -07:00
ff56f78594 fix(uninstall): remove the REA-owned ZCode fallback registration (#2783)
* fix(uninstall): remove the REA-owned ZCode fallback registration

ZCode loads the user-scope `~/.agents/mcp.json` `mcpServers` fallback
whenever its native `mcp.servers` table is empty. Uninstall removed REA
only from the native table, so a REA-owned fallback entry became eligible
to load again once the last native server was gone, while uninstall
reported ZCode as removed and complete.

Also visit the ZCode fallback as a standard `mcpServers` JSON
configuration in the uninstall host. It goes through the same
ownership check, `.rea.backup`, readback and restore path as every other
client configuration, so other fallback servers and settings, and a
foreign `rea` entry, are preserved. Its result merges into the ZCode item.

Fixes #2664

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(uninstall): preserve the strict ZCode fallback dialect

Address review findings at their producing boundaries and preserve directly affected consumers. Source inspection and git diff --check completed. Tests and automated checks were not run at the user’s explicit request.

* fix(comparison): recheck historical calls-only changes (#2223)

* fix(comparison): preserve incomplete function call evidence

Keep missing, unknown, partial and unresolved call relationships unknown in function comparisons and reconstruction claims. Aggregate IDA caller and callee coverage without overwriting uncertainty.

Validate with 144 focused tests, typecheck/lint, docs checks and test discovery. Real providers were not verified; the full headless IDA session suite requires unavailable Windows native controls.

* fix(comparison): recheck historical calls-only changes

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>

* fix(ci): isolate Linux Hopper process fixture inventory

* fix(android): make DEX descriptor pattern portable

* fix: repair the static-check failures on the default branch (#2895)

* fix: repair the static-check failures on the default branch

The default branch fails its own required `Static checks` job three ways, so
every pull request reports that job as failed regardless of its own change.

- `npm run format:check` reports `src/domain/functionComparison.test.ts` and
  `src/ida/IdaOperationRunner.ts`.
- `npm run lint` reports a duplicate `import { join } from "node:path"` in
  `tests/boundary/mcp/ghidraInventoryMcp.test.ts` (lines 4 and 17).
- `npm run typecheck` reports `format: "devin"` missing from the client format
  union in `src/application/SupportedClients.ts`, and an unchecked index in
  `tests/boundary/cli/clientProfileLifecycle.test.ts`, whose pair array is
  inferred as `string[][]`.

Apply the pinned formatter, drop the duplicate import, list the `"devin"` format
that its own definition already uses, and mark the pair array `as const`.

Validation: `npm run check:fast` and `npm run format:check` pass.

* fix(setup): keep Devin on the JSON configuration dialect

---------

Co-authored-by: 吴佳浩 <wujiahao@supremind.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: Lucien <164857476+dakjdakd@users.noreply.github.com>
Co-authored-by: 吴佳浩Alben <35291372+XiaoBinGan@users.noreply.github.com>
Co-authored-by: 吴佳浩 <wujiahao@supremind.com>
2026-10-13 17:33:26 -07:00
679658b287 fix(reference): report entries beyond the path limit as I/O failures (#2716)
* fix(reference): report entries beyond the path limit as I/O failures

Reference-source traversal joins each child onto the root path. A valid
tree whose nested entries exceed the platform's absolute path limit
(PATH_MAX 1024 on macOS) makes lstat fail with ENAMETOOLONG. The
reader's filesystem error allow-list lacked that code, so
filesystemFailureDetail returned undefined and the native rejection
escaped both readReferenceSource and importReferenceSource instead of
becoming the existing failed `io` entry.

Add ENAMETOOLONG to the allow-list, matching the JavaScript application
reader's list. Readable siblings stay in the graph and the unreachable
entry is recorded with its OS message.

Fixes #2652

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(reference): assert the imported graph keeps readable and failed entries

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(process): report no remaining time after a provider deadline fires

Node timers can fire while performance.now() is still a fraction of a
millisecond short of the monotonic deadline. ProviderStartupDeadline then
reported interruption "timeout" and an aborted signal while remainingMs()
returned ceil(0.x) = 1, so polling loops guarded by remainingMs() > 0
(Hopper and Ghidra startup) could run past an elapsed deadline, and the
real-timer test "aborts a bounded wait when its real monotonic deadline
expires" failed intermittently in CI (expected 1 to be 0).

Once the deadline's own timer has classified a timeout, report zero
remaining time. A deterministic regression test fires the timer with the
monotonic clock 0.4 ms short of the deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(reference): preserve path limit entry uncertainty

* fix(reference): preserve unknown entry failures and identity

* fix(reference): type unknown-kind exclusions and keep them out of symlink counts

The metadata-failure path offers "unknown" to exclusion predicates, but
the predicate type only admitted concrete kinds, which broke the build.
Directory-only secret and ignore rules now also match an unknown-kind
entry (it may be a directory), and the summary counts unknown entries
separately instead of as symlinks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: Codex <codex@users.noreply.github.com>
2026-10-13 17:33:09 -07:00
Danish Rafie EkaputraGitHubKaoru0822factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
a6c3d3a365 docs(skill): route browser actions to scenario capture
Route user browser actions to passive observation and agent actions to declared scenarios. Clarify requested artifacts, step outcomes, connect navigation and cleanup, and the limits of listener and request-initiator evidence. Distinguish discarded raw headers from retained response metadata. Update the bundled skill to version 42 with matching package metadata.

Validation on the synchronized revision: docs (5 tests), typecheck/lint, formatting, 61 focused tests, and real Linux Chromium launch/connect and passive CLI probes. Verified declared steps, requested artifacts, digests, external page preservation, temporary form reset and cleanup. Native Windows/macOS and separate follow-up tools were not rerun. Package install/update and CLI/MCP verification passed earlier on 738d02a, before this main sync.

Co-authored-by: 0xRafie <155859586+0xRafie@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-10-14 08:31:16 +08:00
d82ad1faf0 test(android): preserve malformed DEX prototype rejection (#2917)
Preserve malformed DEX parameter/return rejection with tagged producer errors and exact issue paths.

The equivalent portable descriptor fix is already on main via #2910.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
2026-10-14 08:28:23 +08:00
JANG BYUNGKUNandGitHub 2dc89f81be docs(readme): sync Korean README with the English README (#3046) 2026-10-14 08:27:37 +08:00
morluto 30128a03cf refactor(domain): centralize relationship coverage interpretation 2026-10-14 08:22:50 +08:00
Xiangfang ChenandGitHub e049ef70f7 feat(setup): add WorkBuddy client support (#3041)
* feat(setup): add WorkBuddy client support

Register rea in the global ~/.workbuddy/mcp.json through a new workbuddy
entry in SUPPORTED_CLIENT_DEFINITIONS (json dialect, ~/.workbuddy marker,
~/.workbuddy/skills skill directory; WorkBuddy AI shares the file).
Document the client, extend the unreleased-client note, and cover
registration and uninstall with boundary tests.

* docs(setup): record verified WorkBuddy tool calls

Real-client verification completed: WorkBuddy loads the registration and
real REA tool calls execute through the registered server.
2026-10-14 08:12:33 +08:00
bbf0638e88 fix(javascript): report EventSource and sendBeacon URLs as network endpoints (#3044)
Co-authored-by: xpeng5278-web <330743750+xpeng5278-web@users.noreply.github.com>
2026-10-14 08:10:04 +08:00
242e2b0001 fix(ghidra): preserve function facet and call coverage (#3002)
* fix(ghidra): preserve observed function facet and call coverage

Declare successful query availability in the Java producer, including explicit unavailable pseudocode reasons. Carry observed static call-set coverage through the parsed provider result and Evidence without inventing facts for legacy omissions. Use Ghidra indexed comment addresses over the function body and entry, retaining cancellation checks and sparse collection.

Closes #2951

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>

* fix(ghidra): retain unresolved call coverage in observations

Share decoded unresolved-call facts between dossiers, callee coverage and reference observations. Combine terminal and unresolved observations in one instruction traversal and retain site records only when requested. Keep missing procedure identities incomplete and validate unresolved locations at the provider boundary.

Bind snapshots to the corrected call coverage profile so older complete claims cannot replay under the new semantics. Add real public MCP callback trace/comparison regressions.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>

* fix(ghidra): separate reference targets from callee coverage

Reuse containing-function ownership for resolved reference identities and independently compare those identities with the actual returned callee set. Keep known interior routes out of unresolved site records while retaining partial inventory coverage.

Advance the coverage profile to v3 and reject interim v2 cache bindings. Add a real Ghidra producer regression with restored reference metadata; the previous bridge fails the known-route assertion.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>

* fix(ghidra): measure incoming call coverage across function bodies

Compare incoming reference source identities across the owned function body and entry with the actual returned caller set. Stream caller-only checks and reuse dossier reference observations. Combine caller/callee coverage from observed counts without reinterpreting serialized metadata, and share the body scope owner with comments and references.

Bind snapshots to function-body coverage semantics v4 and preserve the real off-entry caller regression.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>

* fix(ghidra): preserve whole-scope directional call coverage

Check body-and-entry call references independently of decoded instruction flow. Reuse collected dossier references and stream inventory-only coverage checks. Keep caller-only gaps distinct from incomplete outgoing coverage in offline path reconstruction, and invalidate prior snapshot profiles.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>

* fix(mcp): retain compact Evidence field types

Share optional diagnostics across the three tagged relationship coverage states instead of advertising duplicate branches. Preserve coverage validation while keeping compact Evidence properties within the existing reference expansion budget. Verify invalid direction cardinalities against the actual SDK-advertised schema.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>

---------

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-14 07:52:14 +08:00
20e4f83097 fix(ghidra): fail doctor when the JVM os.arch selects an unvalidated decompiler directory (#2792)
* fix(ghidra): fail doctor when the JVM os.arch selects an unvalidated decompiler directory

probeJava now also reads the JVM os.arch property. When it maps to a
different native decompiler directory than the one validated from the
host architecture (for example an x86_64 JDK under Rosetta on Apple
silicon selecting os/mac_x86_64 while only mac_arm_64 was built), the
native_decompiler check fails with a remediation pointing at a
matching-arch JDK instead of reporting a healthy installation that
fails every analysis. Unknown os.arch values keep the previous
behavior.

* fix(ghidra): tighten JVM arch remediation and cover os.arch extraction

Address review findings on the JVM os.arch decompiler-directory check:
- The remediation no longer suggests building the decompiler for the
  JVM-selected directory, which could never clear the check; it only
  advises pointing JAVA_HOME at a matching-architecture JDK.
- Add a test running the real Java probe against captured
  -XshowSettings:properties output, asserting os.arch reaches the
  installation diagnostics.
- Document the JDK-architecture prerequisite in the Ghidra setup guide.

* fix(ghidra): refuse unrecognized JVM native architectures

* docs(ghidra): align doctor prerequisites with JVM architecture admission

* fix(ghidra): reject unreported JVM architecture

* test(ghidra): separate JVM and executable admission cases

* fix(ghidra): defer decompiler arch validation when Java is absent

* fix(ghidra): reject JVM native architecture override

* fix(ghidra): validate JVM platform overrides

* fix(ghidra): validate JVM os.name and helper-environment platform overrides

* test(ci): isolate report fixtures and declare PowerPC Java bounds

* test(ghidra): provide observed JVM platform in PowerPC fixture

* fix(ghidra): parse quoted JVM environment options before admission

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-14 07:31:11 +08:00
97aff28201 fix(electron): reject non-executable paths and contain launch rejections (#2705)
* fix(electron): reject non-executable paths and contain launch rejections

An executable_path that runs but never prints the inspector line made
Playwright's electron.launch() leave three waits unawaited. They rejected
unhandled and Node ended the CLI or the MCP server (#2269).

Check the resolved executable_path is a regular file, and on non-Windows
hosts that it passes X_OK, before any launch. Wrap only the launch call
in a scoped unhandledRejection listener that absorbs rejections whose
stack is in playwright-core/lib and re-throws anything else.

Tested on Linux with Node 22.23.1, 24.18.0 and 26.11.1.

Refs #2269

🤖 Generated with Claude Code (Sonnet 5.5, verified by Opus 5.5) 🦊

* fix(electron): absorb only this launch's orphaned rejections

The listener around the launch dropped every rejection with a
playwright-core stack, so an unrelated Playwright failure during the
launch window would vanish. Hold those rejections instead. When the
launch settles, drop only the ones whose message is part of the launch's
own error (the orphans of a failed launch). Raise every other one again
through the unhandledRejection path, so the process still fails. A launch
that succeeds raises all of them. Rejections without a Playwright stack
still fail at once.

One setImmediate turn before the decision lets rejections queued by the
settling tick arrive first. It is a single turn, not a timer. A shared
set of absorbed errors stops overlapping launches from raising each
other's orphans. The listener is removed on every path.

Add child-process tests that drive the built provider through its launch
seam, since Vitest's worker has its own unhandledRejection listener.

🤖 Generated with Claude Code (Sonnet 5.5, verified by Opus 5.5) 🦊

* test(electron): distinguish launch success from capture ownership

* test(electron): assert cleanup ownership failures around overlapping launches

* fix(comparison): recheck historical calls-only changes (#2223)

* fix(comparison): preserve incomplete function call evidence

Keep missing, unknown, partial and unresolved call relationships unknown in function comparisons and reconstruction claims. Aggregate IDA caller and callee coverage without overwriting uncertainty.

Validate with 144 focused tests, typecheck/lint, docs checks and test discovery. Real providers were not verified; the full headless IDA session suite requires unavailable Windows native controls.

* fix(comparison): recheck historical calls-only changes

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>

* fix(android): make DEX descriptor pattern portable

* fix(ci): isolate Linux Hopper process fixture inventory

* fix(browser): avoid replaying observed launch rejections

* style: format comparison and IDA sources

* test(electron): allow cold macOS ownership setup

* test(electron): allow cold macOS setup in the early-exit path test

The test that runs an executable which exits at once builds the Swift
ownership helper twice on macOS: in the CLI process, and again in the
MCP server process on its first tool call that prepares. A cold Swift
cache can spend up to 120 seconds on a build. The test had only the
30 second default.

Give the test 180 seconds, as e1579ec31 did for
electronLaunchRejections.test.ts and 65a809a0d did for
webSourceLocationCli.test.ts. cli.run has no deadline of its own, so
it needs no timeoutMs.

🤖 Generated with Claude Code (Sonnet 5.5, verified by Opus 5.5) 🦊

* test(ci): isolate report fixtures and declare PowerPC Java bounds

---------

Co-authored-by: Claude Opus 5.5 <opus@fox.invalid>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: Lucien <164857476+dakjdakd@users.noreply.github.com>
2026-10-14 07:30:14 +08:00
641a8395ee fix(cli): refuse a JavaScript integrity policy the binary analyze lane cannot honor (#2808)
* fix(cli): refuse an integrity policy the binary analyze lane cannot honor

`analyze` declares --integrity-policy for its JavaScript lane and forwards it
only there. On a binary target the flag was accepted and then dropped:
inspect_artifact-style integrity handling does not exist on this path, and
binary_overview is a strictObject with no input at all, so the request could
never take effect and the run proceeded as if the caller had not asked.

The same branch already refuses source_paths on a binary target with a typed
AnalysisInputError, and the sibling `inspect` command refuses the flag outright,
so the refusal is the established handling here. The policy option carries a
default, so the check keys off a value that differs from the owner schema's own
default rather than the flag's presence; otherwise every binary analyze would be
refused. The default is read from the schema instead of being restated.

Verified with 5 integrity policy CLI cases, check:fast and
verify:test-discovery (1023 files) on macOS arm64, and by running the built CLI
for the no-flag, explicit-default and record-and-continue cases. The regression
case failed with the command reaching the binary lane before the change.

* fix(cli): reject explicit default integrity policies for binaries

* fix(cli): keep an omitted integrity policy absent on the binary lane

`javascriptApplicationOptions.shape.integrityPolicy` fills an omission with its
default, and `.optional()` keeps reporting that parsed value, so
`options.integrityPolicy !== undefined` was true for every `analyze <target>`
call: ordinary binary analysis was rejected before the target was even read.

List `z.undefined()` first in a union so an omitted option stays absent while
the owner still validates a supplied value, and reapply the owner's metadata so
help and `--schema` keep describing the option.

Validation: tests/boundary/cli/javascriptIntegrityPolicyCli.test.ts reports
1 failed | 5 passed without this change (the case "still routes a binary
analyze target without an integrity policy" returned invalid_request for
integrity_policy instead of reaching the target) and 6 passed with it;
`npm run check:fast` passes on macOS 26.5.1 arm64 with Node.js 24.18.0.

* fix(cli): omit integrity defaults from binary option schema

* test(ci): isolate report fixtures and declare PowerPC Java bounds

---------

Co-authored-by: 吴佳浩 <wujiahao@supremind.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-14 07:28:00 +08:00
62 changed files with 3175 additions and 401 deletions
@@ -2,7 +2,7 @@
name: reverse-engineer-anything 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. 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: metadata:
version: "41" version: "42"
--- ---
# REA # 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 consult the NativeAOT workflow in `references/native-and-artifacts.md` for
optional metadata recovery and its supported host/layout boundary. optional metadata recovery and its supported host/layout boundary.
- User-owned browser page already open: `list_browser_targets`. - 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 Everywhere traffic or a native `.saz` capture: read the
[Fiddler workflow](references/runtime-observation.md#fiddler-everywhere-traffic-debugging). [Fiddler workflow](references/runtime-observation.md#fiddler-everywhere-traffic-debugging).
Inspect saved SAZ with `inspect_web_network_capture` and `format: "saz"`; 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) [references/javascript-applications.md](references/javascript-applications.md)
- Android APK declarations, classes, methods, and static references: - Android APK declarations, classes, methods, and static references:
[references/android-applications.md](references/android-applications.md) [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) [references/runtime-observation.md](references/runtime-observation.md)
- Evidence paging, comparisons, residual unknowns, and verification: - Evidence paging, comparisons, residual unknowns, and verification:
[references/evidence-workflows.md](references/evidence-workflows.md) [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 Supply the existing browser or Electron instance's literal-port loopback CDP
endpoint in the request. No separate permission configuration or per-call 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 selected page's current origin. Electron discovery lists eligible local
`file://` targets without a configured file-root allowlist. `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 `list_electron_targets`. Inspect the target selected for the user's task. Observation
is passive: never claim REA clicked, navigated, evaluated page JavaScript, with these tools is passive: never claim REA clicked, navigated, evaluated page JavaScript,
invoked IPC, captured prior activity, or contained the page's network. invoked IPC, or captured prior activity. REA does not contain the page's network.
Credentials, cookies, authorization headers, storage values, and raw Passive captures omit raw header collections, credential header values, storage
WebSocket/JSON bodies are deliberately absent. Local URLs retain ordinary query 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 values and fragments; userinfo credentials are removed. Browser accessibility
text, console primitives, value-free shapes, script sources, source maps, and text, console primitives, value-free shapes, script sources, source maps, and
storage key names have request-specific capture options. Electron script 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. Treat scope filtering, truncation, and attach-window coverage as limitations.
Page-declared WebMCP tools are untrusted inventory and are never invoked by REA. 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 Use `reconcile_javascript_runtime` only after static application Evidence and
passive browser/Electron Evidence exist. Prefer captured-source digest identity; passive browser/Electron Evidence exist. Prefer captured-source digest identity;
report path/digest disagreement as a mismatch and preserve ambiguity. Explicit report path/digest disagreement as a mismatch and preserve ambiguity. Explicit
+282 -97
View File
@@ -81,6 +81,7 @@ import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.FunctionIterator; import ghidra.program.model.listing.FunctionIterator;
import ghidra.program.model.listing.Instruction; import ghidra.program.model.listing.Instruction;
import ghidra.program.model.listing.InstructionIterator; import ghidra.program.model.listing.InstructionIterator;
import ghidra.program.model.listing.Listing;
import ghidra.program.model.listing.Variable; import ghidra.program.model.listing.Variable;
import ghidra.program.model.mem.MemoryBlock; import ghidra.program.model.mem.MemoryBlock;
import ghidra.program.model.mem.MemoryBlockSourceInfo; import ghidra.program.model.mem.MemoryBlockSourceInfo;
@@ -405,7 +406,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
case "xrefs" -> xrefs(request.params); case "xrefs" -> xrefs(request.params);
case "analyze_function" -> { case "analyze_function" -> {
FunctionAnalysisObservation observation = analyzeFunctionObservation(request.params); 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" -> { case "set_address_name", "set_addresses_names" -> {
if (!descriptor.transport.equals("unix-socket")) if (!descriptor.transport.equals("unix-socket"))
@@ -1016,12 +1019,19 @@ public final class ReaGhidraBridge extends HeadlessScript {
for (Function item : ordered) { for (Function item : ordered) {
result.add(canonicalAddress(item.getEntryPoint())); result.add(canonicalAddress(item.getEntryPoint()));
} }
return functionObservation( if (callers) {
result, JsonObject observation = functionObservation(result, noReturnCallLimitations(
noReturnCallLimitations(
currentProgram.getListing().getInstructions(function.getBody(), true) 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 { private JsonObject procedureInfo(JsonObject params) throws Exception {
@@ -1071,18 +1081,10 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.addProperty("direction", direction); result.addProperty("direction", direction);
result.add("references", edges); result.add("references", edges);
result.addProperty("reference_kinds_available", true); result.addProperty("reference_kinds_available", true);
JsonArray unresolvedCalls = new JsonArray(); JsonArray unresolvedCalls = direction.equals("outgoing")
if (direction.equals("outgoing")) for (Instruction instruction : scanInstructions(function, Integer.MAX_VALUE).instructions) { ? callBoundaryObservation(
if (!instruction.getFlowType().isCall()) continue; function, currentProgram.getListing().getInstructions(function.getBody(), true), null, true, references
boolean resolved = false; ).unresolvedCalls() : new JsonArray();
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);
}
}
result.add("unresolved_calls", unresolvedCalls); result.add("unresolved_calls", unresolvedCalls);
return result; return result;
} }
@@ -1490,16 +1492,28 @@ public final class ReaGhidraBridge extends HeadlessScript {
InstructionScan scan = scanInstructions(function, Integer.MAX_VALUE); InstructionScan scan = scanInstructions(function, Integer.MAX_VALUE);
DecompileResults decompilation = decompile(function); DecompileResults decompilation = decompile(function);
String pseudocode = decompiledText(decompilation); String pseudocode = decompiledText(decompilation);
List<Reference> incomingReferences = collectReferences(function, "incoming") JsonObject availability = new JsonObject();
.stream() 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())) .filter(reference -> !function.getBody().contains(reference.getFromAddress()))
.toList(); .toList();
List<Reference> outgoingReferences = collectReferences(function, "outgoing"); List<Reference> outgoingReferences = collectReferences(function, "outgoing");
JsonArray incoming = referenceEdges(incomingReferences); JsonArray incoming = referenceEdges(incomingReferences);
JsonArray outgoing = referenceEdges(outgoingReferences); JsonArray outgoing = referenceEdges(outgoingReferences);
JsonArray comments = comments(scan.instructions); JsonArray comments = comments(function);
JsonArray callers = procedureIdentities(function.getCallingFunctions(monitor)); Set<Function> callerFunctions = function.getCallingFunctions(monitor);
JsonArray callees = procedureIdentities(function.getCalledFunctions(monitor)); JsonArray callers = procedureIdentities(callerFunctions);
Set<Function> calleeFunctions = function.getCalledFunctions(monitor);
JsonArray callees = procedureIdentities(calleeFunctions);
JsonArray referencedStrings = referencedStrings(outgoingReferences); JsonArray referencedStrings = referencedStrings(outgoingReferences);
JsonArray referencedNames = referencedNames(outgoingReferences); JsonArray referencedNames = referencedNames(outgoingReferences);
JsonArray blocks = basicBlockValues(function); JsonArray blocks = basicBlockValues(function);
@@ -1511,7 +1525,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
procedure.add("locals", functionLocals(function)); procedure.add("locals", functionLocals(function));
JsonObject result = new JsonObject(); JsonObject result = new JsonObject();
result.add("procedure", procedure); result.add("procedure", procedure);
result.addProperty("pseudocode", pseudocode == null ? "" : pseudocode); result.addProperty("pseudocode", pseudocode);
result.add("assembly", assembly); result.add("assembly", assembly);
result.add("comments", comments); result.add("comments", comments);
result.add("callers", callers); result.add("callers", callers);
@@ -1520,20 +1534,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.add("outgoing_references", outgoing); result.add("outgoing_references", outgoing);
result.add("referenced_strings", referencedStrings); result.add("referenced_strings", referencedStrings);
result.add("referenced_names", referencedNames); 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 swift = swiftProgram();
boolean aarch64 = currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:"); boolean aarch64 = currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:");
SwiftStringLiteralDecode swiftLiterals = swift && aarch64 ? swiftStringLiterals(function) : null; SwiftStringLiteralDecode swiftLiterals = swift && aarch64 ? swiftStringLiterals(function) : null;
@@ -1547,6 +1547,15 @@ public final class ReaGhidraBridge extends HeadlessScript {
nativeApiBoundary(function, decompilation) nativeApiBoundary(function, decompilation)
); );
result.add("native_value_flow", nativeValueFlow(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(); JsonArray limitations = new JsonArray();
limitations.add( limitations.add(
"Unresolved computed or indirect flows without target addresses are not represented as reference edges." "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." "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); CallBoundaryObservation calls = callBoundaryObservation(
for (String limitation : boundaryLimitations) { function, scan.instructions, calleeFunctions, true, outgoingReferences);
result.add("unresolved_calls", calls.unresolvedCalls());
for (String limitation : calls.limitations()) {
limitations.add(limitation); limitations.add(limitation);
} }
if (decompilation != null && decompilation.getErrorMessage() != null && if (decompilation != null && decompilation.getErrorMessage() != null &&
@@ -1583,7 +1594,14 @@ public final class ReaGhidraBridge extends HeadlessScript {
limitations.add("Ghidra decompiler diagnostic: " + decompilation.getErrorMessage()); limitations.add("Ghidra decompiler diagnostic: " + decompilation.getErrorMessage());
} }
result.add("limitations", limitations); 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 { private JsonObject nativeValueFlow(DecompileResults decompilation) throws Exception {
@@ -3287,54 +3305,202 @@ public final class ReaGhidraBridge extends HeadlessScript {
return result; 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) private List<String> noReturnCallLimitations(Iterable<Instruction> instructions)
throws Exception { throws Exception {
List<String> limitations = new ArrayList<>(); List<String> limitations = new ArrayList<>();
for (Instruction instruction : instructions) { for (Instruction instruction : instructions) {
monitor.checkCancelled(); monitor.checkCancelled();
if (!instruction.getFlowType().isCall() || !instruction.getFlowType().isTerminal()) { String limitation = terminalCallLimitation(instruction);
continue; if (limitation != null) limitations.add(limitation);
}
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());
} }
return limitations; 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) { private static String renderAssembly(List<Instruction> instructions) {
return String.join("\n", renderAssemblyLines(instructions)); return String.join("\n", renderAssemblyLines(instructions));
} }
@@ -3358,15 +3524,27 @@ public final class ReaGhidraBridge extends HeadlessScript {
private List<Reference> collectReferences( private List<Reference> collectReferences(
Function function, Function function,
String direction) throws Exception { 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 // References can target instruction interiors or originate in embedded
// data. Use actual body ownership, including an external's exact entry. // data. Use actual body ownership, including an external's exact entry.
AddressSet scope = new AddressSet(function.getBody()); AddressSet scope = functionObservationScope(function);
scope.add(function.getEntryPoint());
boolean outgoing = direction.equals("outgoing"); boolean outgoing = direction.equals("outgoing");
AddressIterator addresses = outgoing AddressIterator addresses = outgoing
? currentProgram.getReferenceManager().getReferenceSourceIterator(scope, true) ? currentProgram.getReferenceManager().getReferenceSourceIterator(scope, true)
: currentProgram.getReferenceManager().getReferenceDestinationIterator(scope, true); : currentProgram.getReferenceManager().getReferenceDestinationIterator(scope, true);
TreeMap<String, Reference> observed = new TreeMap<>();
while (addresses.hasNext()) { while (addresses.hasNext()) {
monitor.checkCancelled(); monitor.checkCancelled();
Address address = addresses.next(); Address address = addresses.next();
@@ -3374,7 +3552,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
for (Reference reference : currentProgram.getReferenceManager() for (Reference reference : currentProgram.getReferenceManager()
.getReferencesFrom(address)) { .getReferencesFrom(address)) {
monitor.checkCancelled(); monitor.checkCancelled();
addReference(observed, reference); if (!reference.isEntryPointReference()) visitor.accept(reference);
} }
} }
else { else {
@@ -3382,19 +3560,14 @@ public final class ReaGhidraBridge extends HeadlessScript {
.getReferencesTo(address); .getReferencesTo(address);
while (iterator.hasNext()) { while (iterator.hasNext()) {
monitor.checkCancelled(); 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) { private static void addReference(TreeMap<String, Reference> observed, Reference reference) {
if (reference.isEntryPointReference()) {
return;
}
String key = canonicalAddress(reference.getFromAddress()) + "\u0000" + String key = canonicalAddress(reference.getFromAddress()) + "\u0000" +
canonicalAddress(reference.getToAddress()) + "\u0000" + canonicalAddress(reference.getToAddress()) + "\u0000" +
reference.getReferenceType().getName() + "\u0000" + reference.getReferenceType().getName() + "\u0000" +
@@ -3403,6 +3576,12 @@ public final class ReaGhidraBridge extends HeadlessScript {
observed.putIfAbsent(key, reference); 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) { private JsonArray referenceEdges(List<Reference> references) {
JsonArray result = new JsonArray(); JsonArray result = new JsonArray();
for (Reference reference : references) { for (Reference reference : references) {
@@ -3556,23 +3735,28 @@ public final class ReaGhidraBridge extends HeadlessScript {
return result; 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<>(); TreeMap<String, JsonObject> observed = new TreeMap<>();
for (Instruction instruction : instructions) { while (addresses.hasNext()) {
monitor.checkCancelled();
Address address = addresses.next();
for (CommentType type : CommentType.values()) { for (CommentType type : CommentType.values()) {
String text = instruction.getComment(type); String text = listing.getComment(type, address);
if (text == null || text.isEmpty()) { if (text == null || text.isEmpty()) {
continue; continue;
} }
String kind = type == CommentType.EOL ? "inline" : "comment"; String kind = type == CommentType.EOL ? "inline" : "comment";
JsonObject item = new JsonObject(); JsonObject item = new JsonObject();
item.addProperty("address", canonicalAddress(instruction.getAddress())); item.addProperty("address", canonicalAddress(address));
item.addProperty("kind", kind); item.addProperty("kind", kind);
item.addProperty("text", text); item.addProperty("text", text);
String key = canonicalAddress(instruction.getAddress()) + "\u0000" + String key = canonicalAddress(address) + "\u0000" +
kind + "\u0000" + type.name() + "\u0000" + text; kind + "\u0000" + type.name() + "\u0000" + text;
observed.put(key, item); observed.put(key, item);
} }
} }
JsonArray result = new JsonArray(); JsonArray result = new JsonArray();
observed.values().forEach(result::add); 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 InventoryItem(Address address, String value, JsonObject facts) {}
private record AddressNameRequest(String inputAddress, Address address, String name) {} private record AddressNameRequest(String inputAddress, Address address, String name) {}
private record FunctionEntry(Function function, InventoryItem item) {} 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 SwiftStringLiteralDecode(JsonArray literals, JsonArray omissions) {}
private record SwiftLiteralLayout( private record SwiftLiteralLayout(
String representation, String representation,
+20 -1
View File
@@ -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; documentation describes the raw JSON editor without specifying those paths;
loading this registration in Trae remains unverified. 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 ## ZCode
For [ZCode](https://zcode.z.ai/en/docs/mcp-services), setup registers `rea` in the nested `mcp.servers` table of the 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 `mcpServers` entries into the native table so they keep loading, and leaves
the fallback file untouched. Existing REA environment overrides are preserved the fallback file untouched. Existing REA environment overrides are preserved
when refreshing its command. A malformed or unreadable fallback file fails setup instead of 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 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 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 uses strict JSON; setup refuses comments, trailing commas and a BOM rather than
+159 -3
View File
@@ -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 installs the matching skill and on-demand references in the same plan, and adds
`startup_timeout_sec = 30` for Codex and Grok Build. `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 ## Check readiness for your task
Run `rea doctor` when you need diagnosis. It reads host prerequisites and agent 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` | | Qoder | `qoder` |
| Factory Droid | `droid` | | Factory Droid | `droid` |
| Trae | `trae` | | Trae | `trae` |
| WorkBuddy | `workbuddy` |
| ZCode | `zcode` | | ZCode | `zcode` |
| DeepSeek Harness | `dsh` | | DeepSeek Harness | `dsh` |
`dsh` and `kimi` require a release containing them. The registry check above does `dsh`, `kimi`, and `workbuddy` require a release containing them. The registry
not cover them. check above does not cover them.
See [client configuration and troubleshooting](client-configuration.md) for See [client configuration and troubleshooting](client-configuration.md) for
configuration paths, profiles, trust policies, and schema-limit workarounds. 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, REA connects to an existing Ghidra installation on Linux x64/arm64, macOS x64/arm64,
or experimental Windows x64 P0. or experimental Windows x64 P0.
It accepts Ghidra 12.1.x and the 64-bit full JDK declared by that installation's 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 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 and JDK 21. Each installation must include the native decompiler for the host
architecture in `Ghidra/Features/Decompiler/os/<platform>/` or the corresponding 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, Doctor validates the platform, architecture, Ghidra 12.1.x application version,
`support/analyzeHeadless` or `support/analyzeHeadless.bat`, the installation's `support/analyzeHeadless` or `support/analyzeHeadless.bat`, the installation's
Java major range, 64-bit JDK bitness, and the presence of `javac`/`javac.exe`. 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 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 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, environment entry in its plan, writes only after approval, and never downloads,
+22 -25
View File
@@ -20,7 +20,7 @@
**[웹사이트](https://rea.tools/ko/) · [가이드](https://rea.tools/ko/guides/) · [사례](https://rea.tools/ko/showcase/)** **[웹사이트](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> <code>npx rea-agents setup</code>
@@ -56,7 +56,7 @@
REA는 에이전트를 분석 도구에 연결해 네이티브 바이너리, JavaScript와 Electron 앱, .NET 어셈블리, 웹사이트를 검사할 수 있게 합니다. 터미널에서도 같은 도구를 사용할 수 있습니다. 분석은 로컬에서 실행되며, 결과에는 각 결론의 근거와 한계가 포함됩니다. REA는 에이전트를 분석 도구에 연결해 네이티브 바이너리, JavaScript와 Electron 앱, .NET 어셈블리, 웹사이트를 검사할 수 있게 합니다. 터미널에서도 같은 도구를 사용할 수 있습니다. 분석은 로컬에서 실행되며, 결과에는 각 결론의 근거와 한계가 포함됩니다.
설정 과정은 에이전트에 REA를 등록하고 해당 버전에 맞는 워크플로 지침을 설치합니다. 네이티브 분석에는 기존 Hopper 또는 Ghidra 설치를 사용할 수 있습니다. 승인하면 설정 과정에서 Hopper를 설치할 수도 있습니다. 정적 JavaScript 분석에는 두 엔진 모두 필요하지 않습니다. 설정 과정은 에이전트에 REA를 등록하고 해당 버전에 맞는 워크플로 지침을 설치합니다. 네이티브 분석에는 기존 Hopper, Ghidra 또는 IDA 설치를 사용할 수 있습니다. 승인하면 설정 과정에서 Hopper를 설치할 수도 있습니다. 정적 JavaScript 분석에는 네이티브 분석 엔진이 필요하지 않습니다.
> **[REA 웹사이트](https://rea.tools/ko/)**에서 설정 방법, 그림으로 설명한 가이드, 실제 사례를 확인하세요. > **[REA 웹사이트](https://rea.tools/ko/)**에서 설정 방법, 그림으로 설명한 가이드, 실제 사례를 확인하세요.
@@ -105,12 +105,14 @@ rea --help
REA는 빠르게 바뀌며 새 릴리스에는 버그 수정이 자주 포함됩니다. 설치된 버전을 최신으로 유지하세요. REA는 빠르게 바뀌며 새 릴리스에는 버그 수정이 자주 포함됩니다. 설치된 버전을 최신으로 유지하세요.
npm으로 설치한 CLI에서는 다음 명령을 사용합니다. `npm install --global` 또는 `bun add --global`로 설치한 CLI에서는 다음 명령을 사용합니다.
```bash ```bash
rea update rea update
``` ```
Bun 전역 업데이트에는 Bun 1.2.17 이상이 필요합니다. 이전 버전의 Bun을 사용한다면 `rea update`를 실행하기 전에 Bun을 직접 업그레이드하세요.
에이전트 등록과 스킬을 갱신하려면 업데이트 과정에서 출력된 설정 명령을 실행하세요. 에이전트 등록과 스킬을 갱신하려면 업데이트 과정에서 출력된 설정 명령을 실행하세요.
`npx`를 사용한다면 다음 명령으로 에이전트 설정을 갱신하세요. `npx`를 사용한다면 다음 명령으로 에이전트 설정을 갱신하세요.
@@ -119,7 +121,7 @@ rea update
npx rea-agents@latest setup 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의 작동 방식 ## 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/) | | 네이티브 바이너리 | 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조 | Hopper, Ghidra 또는 IDA; [네이티브 분석](https://rea.tools/ko/guides/native/) |
| 오프라인 ELF 레이아웃 | 섹션, 세그먼트, 원본 심볼/재배치 정보, 정적 분석으로 얻은 보안 완화 기법 후보 | Linux x64에서 호출자가 제공하는 pwntools; [바이너리 진단](../binary-diagnostics.md) | | 오프라인 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) | | 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) | | 기록된 Linux 크래시 | 원시 note 레코드, 기록된 모든 스레드의 레지스터/시그널, 선택적 매핑 후보 | Linux x64에서 호출자가 제공하는 pwntools; GDB/pwndbg는 선택 사항; [기록된 크래시](../recorded-crashes.md) |
| JavaScript / Electron | 모듈, 임포트, 소스 맵, 라우트, IPC, 네이티브 애드온 관계 | Node.js와 npm; [애플리케이션 분석](https://rea.tools/ko/guides/javascript/) | | JavaScript / Electron | 모듈, 임포트, 소스 맵, 라우트, IPC, 네이티브 애드온 관계 | Node.js와 npm; [애플리케이션 분석](https://rea.tools/ko/guides/javascript/) |
| 웹사이트 | 페이지 구조, 스크립트, 네트워크 관찰 결과, 요청한 스크린샷 | Chrome 계열 브라우저; [브라우저 분석](https://rea.tools/ko/guides/browser/) | | 웹사이트 | 페이지 구조, 스크립트, 네트워크 관찰 결과, 요청한 스크린샷 | Chrome 계열 브라우저; [브라우저 분석](https://rea.tools/ko/guides/browser/) |
| 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](../web-network-captures.md) | | 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR 또는 Fiddler Everywhere SAZ; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](../web-network-captures.md) |
| 패킷 캡처 | 엔드포인트, DNS, TLS SNI, 프로토콜 분포, HTTP 요청 | 호출자 제공 tshark 및 capinfos; [pcap 가이드](../pcap-analysis.md) | | .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 별도 도구 불필요(정적 검사만); [관리 코드 가이드](../managed-code-analysis.md) |
| .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 정적 검사; [관리 코드 가이드](../managed-code-analysis.md) |
| Android APK | 매니페스트 선언, 클래스, 디컴파일된 메서드, 참조 | Linux/macOS/Windows x64의 헤드리스 JADX와 전체 JDK; [Android 가이드](../android-analysis.md) | | Android APK | 매니페스트 선언, 클래스, 디컴파일된 메서드, 참조 | Linux/macOS/Windows x64의 헤드리스 JADX와 전체 JDK; [Android 가이드](../android-analysis.md) |
| Android 기기 | 기기, 패키지, 프로세스, 로그, 덤프, 화면 캡처, APK/파일 전송 | 호출자 제공 adb(에뮬레이터 또는 실제 기기); [ADB 가이드](../adb-device-analysis.md) | | Android 기기 | 기기, 패키지, 프로세스, 로그, 덤프, 화면 캡처, APK/파일 전송 | 호출자 제공 adb(에뮬레이터 또는 실제 기기); [ADB 가이드](../adb-device-analysis.md) |
| JEB 프로젝트 | 엔진 기반 프로젝트 유닛, 아티팩트 해시, 타입/메서드 의사코드 | 호출자가 시작하고 MCP를 제공하는 JEB 클라이언트(REA_JEB_MCP_URL로 선택); [JEB 가이드](../jeb-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) | | Flutter 앱 | Dart 스냅샷 해시, ABI별 라이브러리 해시, 엔진 빌드 출처 | 없음 (정적 APK 파싱); [Flutter 가이드](../flutter-analysis.md) |
| 펌웨어 | 영역, 추출 결과, 네이티브 분석으로 전달할 정보 | Linux의 Binwalk / Unblob; [펌웨어 가이드](../firmware-analysis.md) | | 펌웨어 | 영역, 추출 결과, 네이티브 분석으로 전달할 정보 | Linux의 Binwalk / Unblob; [펌웨어 가이드](../firmware-analysis.md) |
| 기록된 패킷 | 선택 필드, 반복 값, 필터 및 디코딩 규칙 | Linux: TShark 4.x + prlimit; [캡처 가이드](../packet-captures.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) | | 기록된 논리 신호 | 디코더 주석, 샘플 범위, 진단 정보 | Linux: sigrok-cli 0.7.2 + prlimit; [신호 기록 가이드](../signal-recordings.md) |
| 패키지 및 리소스 | 파일 목록, 다이제스트, plist, Apple 번들 구조, 추출한 리소스 | [아티팩트 및 JavaScript 가이드](../javascript-artifact-reconstruction.md), [Apple 애플리케이션](../apple-application-analysis.md) | | 패키지 및 리소스 | 파일 목록, 다이제스트, plist, Apple 번들 구조, 추출한 리소스 | [아티팩트 및 JavaScript 가이드](../javascript-artifact-reconstruction.md), [Apple 애플리케이션](../apple-application-analysis.md) |
| 프로세스 동작 | 터미널 출력, 상호작용, 종료 및 파일 시스템 관찰 결과, 실행 비교 | 네이티브 PTY를 지원하는 Linux/macOS; [프로세스 캡처](../process-capture.md) | | 프로세스 동작 | 터미널 출력, 상호작용, 종료 및 파일 시스템 관찰 결과, 실행 비교 | 네이티브 PTY를 지원하는 Linux/macOS; [프로세스 캡처](../process-capture.md) |
정적 JavaScript 및 .NET 검사는 제공된 파일을 읽으며 애플리케이션을 실행하지 않습니다. 런타임 캡처는 사용자의 권한으로 선택한 대상을 실행하거나 대상과 상호작용합니다. 각 런타임 가이드에서 그 영향을 설명합니다. 정적 JavaScript 및 .NET 검사는 제공된 파일을 읽으며 애플리케이션을 실행하지 않습니다. 런타임 캡처는 사용자의 권한으로 선택한 대상을 실행하거나 대상과 상호작용합니다. 각 런타임 가이드에서 그 영향을 설명합니다.
오프라인 네이티브 바이너리 검사에는 호출자가 제공하는 GNU objdump와 Rizin도 사용할 수 있습니다. 제공자 설정, CLI 명령, Evidence의 한계는 [리버스 엔지니어링 도구 가이드](../reverse-engineering-tools.md)를 참고하세요. REA는 objdump와 Rizin을 설치하거나 업데이트하지 않습니다.
<a id="choosing-a-deep-analysis-provider"></a> <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> <details>
<summary><strong>Hopper를 먼저 실행해야 하나요?</strong></summary> <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> </details>
@@ -239,7 +246,7 @@ REA는 대상을 로컬에서 분석합니다. 에이전트는 도구 결과를
먼저 업데이트하세요. 최근 릴리스에서 이미 수정되었을 수 있습니다. 먼저 업데이트하세요. 최근 릴리스에서 이미 수정되었을 수 있습니다.
npm으로 설치한 CLI에서는 다음 명령을 사용합니다. `npm install --global` 또는 `bun add --global`로 설치한 CLI에서는 다음 명령을 사용합니다.
```bash ```bash
rea update rea update
@@ -286,11 +293,9 @@ REA를 사용하고, 버그를 보고하고, 기능을 요청하고, 빌드를
책임 있는 사용에 관한 안내는 [DISCLAIMER.md](../../DISCLAIMER.md)를 참고하세요. 책임 있는 사용에 관한 안내는 [DISCLAIMER.md](../../DISCLAIMER.md)를 참고하세요.
REA는 법을 준수하는 연구, 교육, 상호운용성 확보, 소프트웨어 보존, 그리고 본인이 소유하거나 조사 권한을 가진 소프트웨어의 분석을 위한 도구입니다. 불법적이거나 허가되지 않은 사용에 대한 프로젝트의 입장은 모든 REA 버전, 배포 채널, 플랫폼에 적용되며, 각 사본에는 해당 라이선스가 그대로 유지됩니다.
The project's position on illegal or unauthorized use covers all REA REA는 법을 준수하는 연구, 교육, 상호운용성 확보, 소프트웨어 보존, 그리고 본인이 소유하거나 조사 권한을 가진 소프트웨어의 분석을 위한 도구입니다.
versions, distribution channels, and platforms; each copy retains its
applicable licenses.
## 기여 ## 기여
@@ -302,14 +307,6 @@ REA에 도움을 보태 주세요! [issue를 등록](https://github.com/morluto/
[MIT](../../LICENSE) [MIT](../../LICENSE)
REA-authored code is licensed under MIT. Third-party material retains its own REA가 작성한 코드는 MIT 라이선스를 따릅니다. 제3자 자료에는 각자의 라이선스와 저작권 고지가 그대로 유지됩니다. 의존성 목록, 보존된 라이선스 원문, 외부 도구 약관은 [제3자 고지](../../THIRD_PARTY_NOTICES.md)를 참고하세요.
licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md)
for the dependency inventory, preserved license texts, and external-tool terms.
**You must legally obtain and be authorized to use commercial tools**, including IDA Pro / Hex-Rays 디컴파일러, Hopper, JEB 등 상용 도구는 해당 라이선스에 따라 **합법적으로 취득하고 사용 권한을 갖춰야 합니다.** REA는 어떤 도구의 라이선스, 키, 활성화 권한도 부여하지 않습니다. 체험판과 데모는 사용자, 기능, 자동화, 출력 공유 제한을 포함한 공급업체 약관을 따릅니다. 크랙 버전, 도난 키, 무단 키 공유, 라이선스 제한 우회는 허용되지 않습니다. 이 안내는 MIT 라이선스나 제3자 라이선스를 변경하지 않습니다.
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.
+82
View File
@@ -215,6 +215,27 @@ async function verifyNativeValueAdapters(
const functionEvidence = parseEvidence(analyzed.structuredContent); const functionEvidence = parseEvidence(analyzed.structuredContent);
assert.equal(functionEvidence.operation, "analyze_function"); assert.equal(functionEvidence.operation, "analyze_function");
assert.equal(functionEvidence.provider.id, "ghidra"); 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( const functionProfile = analysisProfileSchema.parse(
functionEvidence.analysis_profile, functionEvidence.analysis_profile,
); );
@@ -247,6 +268,7 @@ async function verifyNativeValueAdapters(
assert.ok( assert.ok(
comparisonEvidence.evidence_links.includes(functionEvidence.evidence_id), comparisonEvidence.evidence_links.includes(functionEvidence.evidence_id),
); );
await verifyUnresolvedCallEvidence(client);
const bundle = await client.callTool({ const bundle = await client.callTool({
name: "get_evidence_bundle", name: "get_evidence_bundle",
arguments: {}, arguments: {},
@@ -307,3 +329,63 @@ async function verifyNativeValueAdapters(
unknowns: cliGraph.unknowns.length, 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",
);
}
+1 -1
View File
@@ -136,7 +136,7 @@ export async function verifyGhidraBoundaries(
assert.deepEqual(externalDossier.callees, []); assert.deepEqual(externalDossier.callees, []);
assert.deepEqual(externalDossier.facet_availability.pseudocode, { assert.deepEqual(externalDossier.facet_availability.pseudocode, {
available: false, available: false,
reason: "Ghidra did not supply pseudocode.", reason: "Ghidra does not decompile external functions.",
}); });
for (const facet of [ for (const facet of [
"assembly", "assembly",
+41 -25
View File
@@ -1,11 +1,12 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { execFileSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { import {
access,
copyFile, copyFile,
mkdir, mkdir,
mkdtemp, mkdtemp,
readFile,
readdir, readdir,
rmdir,
rm, rm,
stat, stat,
symlink, symlink,
@@ -54,7 +55,7 @@ try {
join(workspace, "dist", "windows", "WindowsNativeLoader.js"), join(workspace, "dist", "windows", "WindowsNativeLoader.js"),
).href; ).href;
const source = `import assert from 'node:assert/strict'; 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 {join} from 'node:path';
import {systemWindowsNativeAuthority} from ${JSON.stringify(loader)}; import {systemWindowsNativeAuthority} from ${JSON.stringify(loader)};
const temp=process.env.TEMP; 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); 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 [name,...extra]=readdirSync(temp);assert.ok(name);assert.equal(extra.length,0);
const path=join(temp,name);const identity=statSync(path,{bigint:true}); const path=join(temp,name);const identity=statSync(path,{bigint:true});
// Ownership follows the retained NTFS handle even when the directory moves. // Delete sharing permits rename; ownership follows the retained NTFS handle.
// A replacement at its old pathname must remain caller-owned. writeFileSync(join(path,'owned-child.txt'),'owned child');
const moved=path+'-renamed';renameSync(path,moved); const renamed=path+'-renamed';renameSync(path,renamed);
assert.equal(statSync(moved,{bigint:true}).ino,identity.ino); 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}); mkdirSync(path);const replacement=statSync(path,{bigint:true});
assert.notEqual(replacement.ino,identity.ino); assert.notEqual(replacement.ino,identity.ino);
const sentinel=join(path,'unrelated.txt');writeFileSync(sentinel,'unrelated replacement');
if(process.argv[2]!=='teardown'){ if(process.argv[2]!=='teardown'){
const second=systemWindowsNativeAuthority();assert.equal(second.available,true); const second=systemWindowsNativeAuthority();assert.equal(second.available,true);
assert.equal(existsSync(moved),false); assert.equal(existsSync(renamed),false);
assert.deepEqual(readdirSync(temp),[name]); assert.deepEqual(readdirSync(temp),[name]);
assert.equal(readFileSync(sentinel,'utf8'),'unrelated replacement');
assert.equal(statSync(path,{bigint:true}).ino,replacement.ino); assert.equal(statSync(path,{bigint:true}).ino,replacement.ino);
assert.equal(systemWindowsNativeAuthority(),second); 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"); const fixture = join(workspace, "probe.mjs");
await writeFile(fixture, source); await writeFile(fixture, source);
for (const mode of ["retry", "teardown"]) { for (const mode of ["retry", "teardown"]) {
const temp = join(workspace, mode); const temp = join(workspace, mode);
await mkdir(temp); await mkdir(temp);
const observed = JSON.parse( const child = spawnSync(process.execPath, [fixture, mode], {
execFileSync(process.execPath, [fixture, mode], { encoding: "utf8",
encoding: "utf8", timeout: 20_000,
timeout: 20_000, windowsHide: true,
windowsHide: true, env: { ...process.env, TEMP: temp, TMP: temp },
env: { ...process.env, TEMP: temp, TMP: temp }, });
}), if (child.error !== undefined) throw child.error;
); assert.equal(child.status, 0, child.stderr);
assert.equal(observed.retainedOwner, true);
assert.deepEqual(await readdir(temp), [observed.replacementName]);
const replacement = join(temp, observed.replacementName);
assert.equal( assert.equal(
(await stat(replacement, { bigint: true })).ino.toString(), child.stderr,
observed.replacementInode, "",
"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( 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 { } finally {
await rm(workspace, { recursive: true, force: true }); await rm(workspace, { recursive: true, force: true });
@@ -167,7 +167,7 @@ export const parseZcodeFallbackConfiguration = (
? {} ? {}
: requireObject(document.mcpServers, "ZCode fallback mcpServers"), : requireObject(document.mcpServers, "ZCode fallback mcpServers"),
serversPath: ["mcpServers"], serversPath: ["mcpServers"],
dialect: "json", dialect: "zcode_fallback",
legacyServers: {}, legacyServers: {},
}; };
}; };
@@ -183,6 +183,7 @@ export const parseClientConfiguration = (
if (format === undefined || format === "unsupported") if (format === undefined || format === "unsupported")
throw new TypeError("client does not have a supported MCP config format"); throw new TypeError("client does not have a supported MCP config format");
// DeepSeek Harness stores a Cordis patch list, not a server table. // DeepSeek Harness stores a Cordis patch list, not a server table.
if (format === "zcode_fallback") return parseZcodeFallbackConfiguration(text);
if (format === "dsh") return parseDshCordisDocument(text); if (format === "dsh") return parseDshCordisDocument(text);
const document = parseDocument(text, format); const document = parseDocument(text, format);
const serversKey = clientConfigurationServersKey(format); const serversKey = clientConfigurationServersKey(format);
+22 -8
View File
@@ -12,7 +12,7 @@ import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.j
import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js"; import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js";
import { readReferenceSource } from "../reference/ReferenceSourceReader.js"; import { readReferenceSource } from "../reference/ReferenceSourceReader.js";
import type { import type {
ReferenceSourceEntryKind, ReferenceSourceExclusionKind,
ReferenceSourceRead, ReferenceSourceRead,
ReferenceSourceReaderError, ReferenceSourceReaderError,
} from "../reference/ReferenceSourceReaderTypes.js"; } from "../reference/ReferenceSourceReaderTypes.js";
@@ -111,7 +111,8 @@ const deriveInventoryState = (
entry.limitations.length > 0 || entry.limitations.length > 0 ||
(entry.kind === "file" && entry.content_state !== "hashed") || (entry.kind === "file" && entry.content_state !== "hashed") ||
(entry.kind === "directory" && entry.tree_state !== "enumerated") || (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"; if (partialEntry) return "partial";
const partialRelationship = input.relationships.some( const partialRelationship = input.relationships.some(
@@ -142,11 +143,24 @@ const createShouldExclude =
exclusions: HistoricalSourceGraphInput["exclusions"], exclusions: HistoricalSourceGraphInput["exclusions"],
secrets: PreparedReferenceSourceImport["secrets"], secrets: PreparedReferenceSourceImport["secrets"],
ignored: PreparedReferenceSourceImport["ignored"], ignored: PreparedReferenceSourceImport["ignored"],
): ((path: string, kind: ReferenceSourceEntryKind) => boolean) => ): ((path: string, kind: ReferenceSourceExclusionKind) => boolean) =>
(path, kind) => { (path, kind) => {
const patternPath = kind === "directory" ? `${path}/` : path; // An entry of unknown kind may be a directory, so directory-only rules
const secretMatch = secrets.test(patternPath); // must still exclude it; this fails closed for caller-marked secrets.
if (secretMatch.ignored) { 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) if (!secretMatch.rule)
throw new Error(`Ignored secret path has no matching rule: ${path}`); throw new Error(`Ignored secret path has no matching rule: ${path}`);
exclusions.push({ exclusions.push({
@@ -156,8 +170,8 @@ const createShouldExclude =
}); });
return true; return true;
} }
const match = ignored.test(patternPath); const match = firstMatch(ignored);
if (!match.ignored) return false; if (match?.ignored !== true) return false;
if (!match.rule) if (!match.rule)
throw new Error(`Ignored path has no matching rule: ${path}`); throw new Error(`Ignored path has no matching rule: ${path}`);
const reason = match.rule.mark; const reason = match.rule.mark;
@@ -45,6 +45,8 @@ const entryRecoveryGuidance = (
return "This directory could not be read. Check its permissions, then try again."; return "This directory could not be read. Check its permissions, then try again.";
if (entry.kind === "symlink") if (entry.kind === "symlink")
return "This symbolic link could not be read safely. Check the link and its permissions, then try again."; 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."; return "This file could not be read. Check its permissions, then try again.";
}; };
@@ -73,6 +75,13 @@ const failedEntry = (
classifications, classifications,
limitations: [limitation], limitations: [limitation],
}; };
if (entry.kind === "unknown")
return {
path: entry.path,
kind: "unknown",
classifications: ["unknown"],
limitations: [limitation],
};
return { return {
path: entry.path, path: entry.path,
kind: "file", kind: "file",
+9
View File
@@ -47,6 +47,7 @@ export interface SetupClient {
| "pi" | "pi"
| "kimi" | "kimi"
| "hermes" | "hermes"
| "zcode_fallback"
| "zcode" | "zcode"
| "dsh" | "dsh"
| "unsupported"; | "unsupported";
@@ -606,6 +607,14 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
markerPath: traeUserDirectory, markerPath: traeUserDirectory,
format: "json", format: "json",
}, },
{
name: "workbuddy",
displayName: "WorkBuddy",
skillPath: [".workbuddy", "skills"],
configPath: [".workbuddy", "mcp.json"],
markerPath: [".workbuddy"],
format: "json",
},
{ {
name: "grok_build", name: "grok_build",
displayName: "Grok Build", displayName: "Grok Build",
+12 -3
View File
@@ -231,12 +231,21 @@ export const systemUninstallHost = (
const clients = supportedClients(home, platform, environment); const clients = supportedClients(home, platform, environment);
redirection = appDataRedirection(clients); redirection = appDataRedirection(clients);
return Promise.resolve( return Promise.resolve(
clients.flatMap((client) => clients.flatMap((client) => [
uninstallConfigPaths(client).map((configPath) => ({ ...uninstallConfigPaths(client).map((configPath) => ({
...client, ...client,
configPath, configPath,
})), })),
), ...(client.zcodeFallbackPath === undefined
? []
: [
{
...client,
configPath: client.zcodeFallbackPath,
format: "zcode_fallback" as const,
},
]),
]),
); );
}, },
inspectClient: async (client) => { inspectClient: async (client) => {
@@ -1,5 +1,6 @@
import { dirname } from "node:path"; 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 { ExecutionOptions } from "../AnalysisProvider.js";
import type { 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 ( const canonicalizeInput = async (
input: ElectronActiveObservationInput, input: ElectronActiveObservationInput,
): Promise<Result<CanonicalElectronActiveObservationInput, AnalysisError>> => { ): Promise<Result<CanonicalElectronActiveObservationInput, AnalysisError>> => {
const [executable, application, root] = await Promise.all([ const [executable, application, root] = await Promise.all([
resolveInputPath("executable_path", input.executable_path), resolveExecutablePath(input.executable_path),
resolveInputPath("application_path", input.application_path), resolveInputPath("application_path", input.application_path),
input.application_root === undefined input.application_root === undefined
? Promise.resolve(ok(undefined)) ? Promise.resolve(ok(undefined))
@@ -96,13 +96,16 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
await systemProcessOwnershipHost.captureBaseline?.(options.signal); await systemProcessOwnershipHost.captureBaseline?.(options.signal);
if (options.signal?.aborted) if (options.signal?.aborted)
throw new BrowserObservationError(OPERATION, "cancelled"); throw new BrowserObservationError(OPERATION, "cancelled");
application = await this.launch({ const launchOptions: Parameters<typeof electron.launch>[0] = {
executablePath: paths.executable, executablePath: paths.executable,
cwd: paths.root, cwd: paths.root,
env: safeElectronEnvironment(this.environment, runId), env: safeElectronEnvironment(this.environment, runId),
args: ["-r", hookPath, paths.application, ...input.args], args: ["-r", hookPath, paths.application, ...input.args],
timeout: Math.max(1, startupDeadline - performance.now()), timeout: Math.max(1, startupDeadline - performance.now()),
}); };
application = await containLaunchRejections(() =>
this.launch(launchOptions),
);
const leaderPid = application.process().pid; const leaderPid = application.process().pid;
if ( if (
typeof leaderPid !== "number" || typeof leaderPid !== "number" ||
@@ -377,6 +380,88 @@ const safeElectronEnvironment = (
["REA_PROCESS_RUN_ID", runId] as const, ["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 => const providerError = (cause: unknown): AnalysisError =>
cause instanceof AnalysisError cause instanceof AnalysisError
? cause ? cause
+34 -2
View File
@@ -45,6 +45,24 @@ const registerOverviewCommands = (
.describe("Load and update a local analysis snapshot"), .describe("Load and update a local analysis snapshot"),
...nativeOpenOptionFields, ...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, { cli.command(CLI_COMMANDS.analyze, {
description: "Get an overview of an app", description: "Get an overview of an app",
args: z.object({ args: z.object({
@@ -54,7 +72,7 @@ const registerOverviewCommands = (
.describe("App, program, or analysis database path"), .describe("App, program, or analysis database path"),
}), }),
options: overviewOptions.extend({ options: overviewOptions.extend({
integrityPolicy: javascriptApplicationOptions.shape.integrityPolicy, integrityPolicy: laneIntegrityPolicy,
sourcePath: javascriptApplicationOptions.shape.sourcePath, sourcePath: javascriptApplicationOptions.shape.sourcePath,
}), }),
run: async ({ args, options }) => { run: async ({ args, options }) => {
@@ -67,7 +85,8 @@ const registerOverviewCommands = (
runCliJavaScriptApplicationAnalysis( runCliJavaScriptApplicationAnalysis(
{ {
input_path: resolve(args.path), input_path: resolve(args.path),
integrity_policy: options.integrityPolicy, integrity_policy:
options.integrityPolicy ?? defaultIntegrityPolicy,
...(options.sourcePath === undefined ...(options.sourcePath === undefined
? {} ? {}
: { source_paths: options.sourcePath }), : { 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( return runDirectAnalysis(
args.path, args.path,
"binary_overview", "binary_overview",
+36
View File
@@ -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", () => { describe("explicit call relationship coverage", () => {
it("accumulates 150,000 partial reasons without argument-stack exhaustion", () => { it("accumulates 150,000 partial reasons without argument-stack exhaustion", () => {
const result = run([ const result = run([
+8 -10
View File
@@ -1,3 +1,4 @@
import { relationshipCoverageLimitations } from "./relationshipCoverage.js";
import { import {
callPathInputSchema, callPathInputSchema,
callPathResultSchema, callPathResultSchema,
@@ -10,7 +11,7 @@ import {
parseFunctionEvidence, parseFunctionEvidence,
type FunctionSnapshot, type FunctionSnapshot,
} from "./functionDossierEvidence.js"; } from "./functionDossierEvidence.js";
import { isCallerOnlyUnknownCoverage } from "./functionCallCoverage.js"; import { isCallerOnlyIncompleteCoverage } from "./functionCallCoverage.js";
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
/** Minimal directed caller-to-callee adjacency for call-path search. */ /** Minimal directed caller-to-callee adjacency for call-path search. */
@@ -240,15 +241,12 @@ const inspectSearch = ({
const coverage = snapshot.evidence.relationship_coverage; const coverage = snapshot.evidence.relationship_coverage;
for (const limitation of snapshot.limitations) for (const limitation of snapshot.limitations)
limitations.push(limitation); limitations.push(limitation);
if (coverage?.status === "partial") if (coverage !== null) {
for (const limitation of coverage.limitations) for (const limitation of relationshipCoverageLimitations(coverage))
limitations.push(limitation); limitations.push(limitation);
else if (coverage?.status === "unknown") for (const diagnostic of coverage.diagnostics ?? [])
limitations.push(coverage.reason); limitations.push(diagnostic);
for (const diagnostic of coverage !== null && "diagnostics" in coverage }
? coverage.diagnostics
: [])
limitations.push(diagnostic);
// The requested goal is a terminal for this search. Its outgoing edges // 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 // cannot add a path to the goal, so its dossier limits are contextual
// but do not make the searched start-to-goal closure incomplete. // but do not make the searched start-to-goal closure incomplete.
@@ -268,7 +266,7 @@ const inspectSearch = ({
coverage === null coverage === null
? snapshot.limitations.length > 0 ? snapshot.limitations.length > 0
: coverage.status !== "complete" && : coverage.status !== "complete" &&
!isCallerOnlyUnknownCoverage(coverage) !isCallerOnlyIncompleteCoverage(coverage)
) )
incompleteExpandedCoverage = true; incompleteExpandedCoverage = true;
} else if (node === goal) { } else if (node === goal) {
+33 -7
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
combineFunctionRelationshipCoverage, combineFunctionRelationshipCoverage,
isCallerOnlyUnknownCoverage, isCallerOnlyIncompleteCoverage,
} from "./functionCallCoverage.js"; } from "./functionCallCoverage.js";
import type { RelationshipCoverage } from "./relationshipCoverage.js"; import type { RelationshipCoverage } from "./relationshipCoverage.js";
@@ -22,14 +22,14 @@ const cappedCallees: RelationshipCoverage = {
}; };
const complete: RelationshipCoverage = { status: "complete" }; const complete: RelationshipCoverage = { status: "complete" };
describe("isCallerOnlyUnknownCoverage", () => { describe("isCallerOnlyIncompleteCoverage", () => {
it("detects unknown callers beside complete callees", () => { it("detects unknown callers beside complete callees", () => {
const combined = combineFunctionRelationshipCoverage( const combined = combineFunctionRelationshipCoverage(
unknownCallers, unknownCallers,
complete, complete,
); );
expect(combined.status).toBe("unknown"); expect(combined.status).toBe("unknown");
expect(isCallerOnlyUnknownCoverage(combined)).toBe(true); expect(isCallerOnlyIncompleteCoverage(combined)).toBe(true);
}); });
it("rejects unknown callees beside complete callers", () => { it("rejects unknown callees beside complete callers", () => {
@@ -37,7 +37,7 @@ describe("isCallerOnlyUnknownCoverage", () => {
complete, complete,
unknownCallees, unknownCallees,
); );
expect(isCallerOnlyUnknownCoverage(combined)).toBe(false); expect(isCallerOnlyIncompleteCoverage(combined)).toBe(false);
}); });
it("rejects unknown coverage on both sides", () => { it("rejects unknown coverage on both sides", () => {
@@ -45,7 +45,7 @@ describe("isCallerOnlyUnknownCoverage", () => {
unknownCallers, unknownCallers,
unknownCallees, unknownCallees,
); );
expect(isCallerOnlyUnknownCoverage(combined)).toBe(false); expect(isCallerOnlyIncompleteCoverage(combined)).toBe(false);
}); });
it("rejects unknown callers beside IDA-capped callees", () => { it("rejects unknown callers beside IDA-capped callees", () => {
@@ -54,11 +54,37 @@ describe("isCallerOnlyUnknownCoverage", () => {
cappedCallees, cappedCallees,
); );
expect(combined.status).toBe("unknown"); expect(combined.status).toBe("unknown");
expect(isCallerOnlyUnknownCoverage(combined)).toBe(false); expect(isCallerOnlyIncompleteCoverage(combined)).toBe(false);
}); });
it("rejects complete coverage", () => { it("rejects complete coverage", () => {
const combined = combineFunctionRelationshipCoverage(complete, complete); 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);
}); });
}); });
+39 -42
View File
@@ -1,4 +1,7 @@
import type { RelationshipCoverage } from "./relationshipCoverage.js"; import {
relationshipCoverageLimitations,
type RelationshipCoverage,
} from "./relationshipCoverage.js";
import type { FunctionDossier } from "./hopperValues.js"; import type { FunctionDossier } from "./hopperValues.js";
import { uniqueSorted } from "./canonicalOrdering.js"; import { uniqueSorted } from "./canonicalOrdering.js";
@@ -15,25 +18,25 @@ export const combineFunctionRelationshipCoverage = (
{ direction: "callees" as const, coverage: callees }, { direction: "callees" as const, coverage: callees },
]; ];
const diagnostics = uniqueSorted( const diagnostics = uniqueSorted(
reads.flatMap(({ coverage }) => reads.flatMap(({ coverage }) => coverage?.diagnostics ?? []),
coverage !== undefined && "diagnostics" in coverage
? coverage.diagnostics
: [],
),
); );
const details = diagnostics.length > 0 ? { 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 reasons = reads.flatMap(({ direction, coverage }) => {
const label = const label =
direction === "callers" ? callerCoveragePrefix : calleeCoveragePrefix; direction === "callers" ? callerCoveragePrefix : calleeCoveragePrefix;
const limitations = return relationshipCoverageLimitations(coverage).map(
coverage === undefined (reason) => `${label}${reason}`,
? ["Provider did not report relationship coverage."] );
: coverage.status === "unknown"
? [coverage.reason]
: coverage.status === "partial"
? coverage.limitations
: [];
return limitations.map((reason) => `${label}${reason}`);
}); });
if ( if (
reads.some( reads.some(
@@ -43,54 +46,48 @@ export const combineFunctionRelationshipCoverage = (
return { return {
status: "unknown", status: "unknown",
reason: uniqueSorted(reasons).join(" "), reason: uniqueSorted(reasons).join(" "),
limited_directions,
...details, ...details,
}; };
if (reasons.length > 0) if (reasons.length > 0)
return { return {
status: "partial", status: "partial",
limitations: uniqueSorted(reasons), limitations: uniqueSorted(reasons),
limited_directions,
...details, ...details,
}; };
return { status: "complete", ...details }; return { status: "complete", ...details };
}; };
/** /**
* Whether combined relationship coverage is unknown only on the caller side. * Forward searches depend on callee coverage. Preserve the historical
* The combine helper above prefixes each direction's reasons, so a reason * unknown-reason encoding when reading observations without explicit directions.
* 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.
*/ */
export const isCallerOnlyUnknownCoverage = ( export const isCallerOnlyIncompleteCoverage = (
coverage: RelationshipCoverage, coverage: RelationshipCoverage,
): boolean => ): boolean => {
coverage.status === "unknown" && if (coverage.status === "complete") return false;
coverage.reason.startsWith(callerCoveragePrefix) && if (coverage.limited_directions !== undefined)
!coverage.reason.includes(calleeCoveragePrefix); 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. */ /** Explain why two resolved call sets cannot establish call-set equality. */
export const functionCallCoverageLimitations = ( export const functionCallCoverageLimitations = (
coverage: RelationshipCoverage | null, coverage: RelationshipCoverage | null,
dossier: Pick<FunctionDossier, "unresolved_calls">, dossier: Pick<FunctionDossier, "unresolved_calls">,
): string[] => { ): string[] => {
const limitations = return uniqueSorted([
coverage === null ...relationshipCoverageLimitations(coverage),
? ["Provider did not report relationship coverage."] ...(coverage?.status === "complete" ? [] : (coverage?.diagnostics ?? [])),
: coverage.status === "unknown"
? [
coverage.reason,
...("diagnostics" in coverage ? coverage.diagnostics : []),
]
: coverage.status === "partial"
? [
...coverage.limitations,
...("diagnostics" in coverage ? coverage.diagnostics : []),
]
: [];
limitations.push(
...(dossier.unresolved_calls ?? []).map( ...(dossier.unresolved_calls ?? []).map(
({ address, reason }) => `Unresolved call at ${address}: ${reason}`, ({ 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, receiverFact?: JavaScriptOpenReceiverFact,
): string | undefined => { ): string | undefined => {
if (receiverFact === "window") return 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]); return stringValue(args[0]);
if (name.endsWith(".open")) if (name.endsWith(".open"))
return xhrOpenUrl(name, args[0], args[1], receiverFact); return xhrOpenUrl(name, args[0], args[1], receiverFact);
@@ -574,6 +580,9 @@ export const endpointArgument = (
? undefined ? undefined
: value; : 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]); if (name.endsWith("loadURL")) return stringValue(args[0]);
return undefined; return undefined;
}; };
+24
View File
@@ -259,6 +259,30 @@ describe("historical source graph", () => {
}); });
describe("partial historical source graphs", () => { 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", () => { it("keeps an unreadable symlink target unknown and out of root identity", () => {
const base = graphInput(); const base = graphInput();
base.inventory_state = "partial"; base.inventory_state = "partial";
+11 -1
View File
@@ -111,6 +111,10 @@ const sourceEntrySchema = z.union([
sourceFileSchema, sourceFileSchema,
sourceDirectorySchema, sourceDirectorySchema,
sourceSymlinkSchema, sourceSymlinkSchema,
z.strictObject({
...entryBaseShape,
kind: z.literal("unknown"),
}),
]); ]);
const sourceRelationshipSchema = z.strictObject({ const sourceRelationshipSchema = z.strictObject({
@@ -361,7 +365,8 @@ const checkCompleteInventory = (
entry.limitations.length > 0 || entry.limitations.length > 0 ||
(entry.kind === "file" && entry.content_state !== "hashed") || (entry.kind === "file" && entry.content_state !== "hashed") ||
(entry.kind === "directory" && entry.tree_state !== "enumerated") || (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( const partialRelationship = graph.relationships.some(
({ parse_state, resolution }) => ({ parse_state, resolution }) =>
@@ -478,6 +483,11 @@ const rootCommitment = (
target: entry.target, target: entry.target,
target_state: entry.target_state, target_state: entry.target_state,
}; };
case "unknown":
return {
kind: entry.kind,
path: entry.path,
};
} }
}), }),
exclusions, exclusions,
+20
View File
@@ -81,6 +81,26 @@ describe("committed historical-source summaries", () => {
expect(graph).toEqual(original); 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", () => { it("sums known bytes losslessly beyond the JSON safe integer range", () => {
const graph = createHistoricalSourceGraph( const graph = createHistoricalSourceGraph(
input( input(
+3 -1
View File
@@ -29,6 +29,7 @@ export const createHistoricalSourceSummary = (
files: 0, files: 0,
directories: 0, directories: 0,
symlinks: 0, symlinks: 0,
unknown: 0,
relationships: graph.relationships.length, relationships: graph.relationships.length,
parse_failures: graph.parse_failures.length, parse_failures: graph.parse_failures.length,
exclusions: graph.exclusions.length, exclusions: graph.exclusions.length,
@@ -59,7 +60,8 @@ export const createHistoricalSourceSummary = (
} else if (entry.kind === "directory") { } else if (entry.kind === "directory") {
counts.directories += 1; counts.directories += 1;
treeStates[entry.tree_state] += 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. // Canonical entries already use Unicode code-point order; filtering preserves it.
if (entry.path.split("/").length <= outlineDepth) if (entry.path.split("/").length <= outlineDepth)
outline.push(outlineEntry(entry)); outline.push(outlineEntry(entry));
+33 -6
View File
@@ -1,24 +1,51 @@
import { z } from "zod"; import { z } from "zod";
const diagnostics = z.array(z.string().min(1)).min(1); const diagnostics = z.array(z.string().min(1)).min(1).exactOptional();
const completeCoverage = z.strictObject({ status: z.literal("complete") }); 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({ const partialCoverage = z.strictObject({
status: z.literal("partial"), status: z.literal("partial"),
limitations: z.array(z.string().min(1)).min(1), limitations: z.array(z.string().min(1)).min(1),
limited_directions: limitedDirections.exactOptional(),
diagnostics,
}); });
const unknownCoverage = z.strictObject({ const unknownCoverage = z.strictObject({
status: z.literal("unknown"), status: z.literal("unknown"),
reason: z.string().min(1), reason: z.string().min(1),
limited_directions: limitedDirections.exactOptional(),
diagnostics,
}); });
/** Explicit coverage state for a provider's observed caller/callee set. */ /** Explicit coverage state for a provider's observed caller/callee set. */
export const relationshipCoverageSchema = z.union([ export const relationshipCoverageSchema = z.discriminatedUnion("status", [
completeCoverage, completeCoverage,
completeCoverage.extend({ diagnostics }),
partialCoverage, partialCoverage,
partialCoverage.extend({ diagnostics }),
unknownCoverage, unknownCoverage,
unknownCoverage.extend({ diagnostics }),
]); ]);
export type RelationshipCoverage = z.infer<typeof relationshipCoverageSchema>; 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];
}
};
+1 -1
View File
@@ -5,5 +5,5 @@ export const PACKAGE_METADATA = {
serverSdkVersion: "2.3.1", serverSdkVersion: "2.3.1",
clientSdkVersion: "2.3.1", clientSdkVersion: "2.3.1",
coreSdkVersion: "2.3.1", coreSdkVersion: "2.3.1",
skillVersion: "41", skillVersion: "42",
} as const; } as const;
+4 -2
View File
@@ -40,6 +40,8 @@ import {
NATIVE_AOT_PROFILE_CONTRACT_REVISION, NATIVE_AOT_PROFILE_CONTRACT_REVISION,
} from "../domain/native/nativeAotPe.js"; } 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 * 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 * 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", load_image_observations: "source-mappings-entry-context-v2",
function_body_evidence: "complete-inclusive-ranges-v1", function_body_evidence: "complete-inclusive-ranges-v1",
function_references: "complete-body-and-entry-reference-manager-v2", function_references: "complete-body-and-entry-reference-manager-v2",
call_relationship_coverage: "resolved-call-reference-diagnostics-v1", call_relationship_coverage: callRelationshipCoverage,
function_dossier_coverage: function_dossier_coverage:
"observed-facets-and-call-relationships-v1", "observed-facets-and-call-relationships-v1",
string_inventory_evidence: "defined-data-coverage-v1", string_inventory_evidence: "defined-data-coverage-v1",
@@ -222,7 +224,7 @@ export const resolveGhidraAnalysisProfile = async (
load_image_observations: "source-mappings-entry-context-v2", load_image_observations: "source-mappings-entry-context-v2",
function_body_evidence: "complete-inclusive-ranges-v1", function_body_evidence: "complete-inclusive-ranges-v1",
function_references: "complete-body-and-entry-reference-manager-v2", 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", function_dossier_coverage: "observed-facets-and-call-relationships-v1",
string_inventory_evidence: "defined-data-coverage-v1", string_inventory_evidence: "defined-data-coverage-v1",
location_resolution: "explicit-address-exact-entry-symbol-first-v3", location_resolution: "explicit-address-exact-entry-symbol-first-v3",
+6 -1
View File
@@ -15,10 +15,13 @@ import { projectGhidraDoctorInspection } from "./GhidraDoctor.js";
const INSTALL = "/opt/ghidra_12.1.4_PUBLIC"; const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
const installationHost = ( const installationHost = (
overrides: Partial<GhidraInstallationHost> = {}, overrides: Partial<GhidraInstallationHost> = {},
platform: NodeJS.Platform = "linux",
): GhidraInstallationHost => ({ ): GhidraInstallationHost => ({
readText: () => "application.version=12.1.4\napplication.java.min=21\n", readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true, executable: () => true,
probeJava: () => ({ probeJava: () => ({
osArch: "amd64",
osName: platform === "win32" ? "Windows 11" : "Linux",
version: "21.0.11", version: "21.0.11",
major: 21, major: 21,
home: "/usr/lib/jvm/jdk-21", home: "/usr/lib/jvm/jdk-21",
@@ -45,7 +48,7 @@ const inspection = (
? {} ? {}
: { installDir: options.installDir }), : { installDir: options.installDir }),
}, },
installationHost(overrides), installationHost(overrides, options.platform ?? "linux"),
); );
const doctorHost = ( const doctorHost = (
@@ -129,6 +132,8 @@ describe("Ghidra doctor integration", () => {
{ installDir: INSTALL }, { installDir: INSTALL },
{ {
probeJava: () => ({ probeJava: () => ({
osArch: "amd64",
osName: "Linux",
version: "17.0.12", version: "17.0.12",
major: 17, major: 17,
home: "/usr/lib/jvm/jdk-17", home: "/usr/lib/jvm/jdk-17",
+113
View File
@@ -43,7 +43,120 @@ const parseFunctionValueFixture = (
return parsed.ok ? { ok: true as const, value: parsed.value.value } : parsed; 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", () => { 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", () => { it("preserves file-offset resolution in Evidence locations and rejects inexact offsets", () => {
const selection = { const selection = {
file_offset: 0x210, file_offset: 0x210,
+16 -1
View File
@@ -27,6 +27,10 @@ import {
import { nativeApiBoundarySchema } from "../domain/native/nativeApiBoundary.js"; import { nativeApiBoundarySchema } from "../domain/native/nativeApiBoundary.js";
import { nativeValueFlowSchema } from "../domain/native/nativeValueFlow.js"; import { nativeValueFlowSchema } from "../domain/native/nativeValueFlow.js";
import type { EvidenceLocation } from "../domain/evidence.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 { err, ok, type Result } from "../domain/result.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import { import {
@@ -307,6 +311,7 @@ const ghidraFunctionDossier = functionDossierSchema
...value.comments.map(({ address }) => address), ...value.comments.map(({ address }) => address),
...value.callers.map(({ address }) => address), ...value.callers.map(({ address }) => address),
...value.callees.map(({ address }) => address), ...value.callees.map(({ address }) => address),
...(value.unresolved_calls ?? []).map(({ address }) => address),
...value.incoming_references.flatMap(referenceAddresses), ...value.incoming_references.flatMap(referenceAddresses),
...value.outgoing_references.flatMap(referenceAddresses), ...value.outgoing_references.flatMap(referenceAddresses),
...value.referenced_strings.flatMap(({ address, source_address }) => [ ...value.referenced_strings.flatMap(({ address, source_address }) => [
@@ -419,6 +424,7 @@ const functionBoundaryObservationOperations: ReadonlySet<GhidraFunctionOperation
const functionObservationEnvelope = z.strictObject({ const functionObservationEnvelope = z.strictObject({
value: z.unknown(), value: z.unknown(),
limitations: z.array(z.string()), limitations: z.array(z.string()),
relationship_coverage: relationshipCoverageSchema.exactOptional(),
selection: z selection: z
.strictObject({ .strictObject({
file_offset: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), file_offset: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
@@ -432,6 +438,7 @@ export interface ParsedGhidraFunctionResult {
readonly value: JsonValue; readonly value: JsonValue;
readonly limitations: readonly string[]; readonly limitations: readonly string[];
readonly locations?: readonly EvidenceLocation[]; readonly locations?: readonly EvidenceLocation[];
readonly relationshipCoverage?: RelationshipCoverage;
} }
/** Require exact, bounded Java-bridge function output before creating Evidence. */ /** Require exact, bounded Java-bridge function output before creating Evidence. */
@@ -442,6 +449,7 @@ export const parseGhidraFunctionResult = (
let observation: { let observation: {
readonly value: unknown; readonly value: unknown;
readonly limitations: readonly string[]; readonly limitations: readonly string[];
readonly relationship_coverage?: RelationshipCoverage;
readonly selection?: z.infer< readonly selection?: z.infer<
typeof functionObservationEnvelope typeof functionObservationEnvelope
>["selection"]; >["selection"];
@@ -450,13 +458,17 @@ export const parseGhidraFunctionResult = (
const envelope = functionObservationEnvelope.safeParse(value); const envelope = functionObservationEnvelope.safeParse(value);
if ( if (
!envelope.success || !envelope.success ||
(operation !== "analyze_function" &&
operation !== "procedure_callers" &&
operation !== "procedure_callees" &&
envelope.data.relationship_coverage !== undefined) ||
(operation !== "procedure_pseudo_code" && (operation !== "procedure_pseudo_code" &&
envelope.data.selection !== undefined) envelope.data.selection !== undefined)
) )
return err( return err(
new AnalysisOutputError( new AnalysisOutputError(
operation, 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 }, envelope.success ? undefined : { cause: envelope.error },
), ),
); );
@@ -469,6 +481,9 @@ export const parseGhidraFunctionResult = (
? ok({ ? ok({
value: jsonValueSchema.parse(parsed.data), value: jsonValueSchema.parse(parsed.data),
limitations: observation.limitations, limitations: observation.limitations,
...(observation.relationship_coverage === undefined
? {}
: { relationshipCoverage: observation.relationship_coverage }),
...(observation.selection === undefined ...(observation.selection === undefined
? {} ? {}
: { : {
+2
View File
@@ -12,5 +12,7 @@ export const ghidraProfileInstallationHost: GhidraInstallationHost = {
home: "/jdk-21", home: "/jdk-21",
bits: 64, bits: 64,
runtime: "jdk", runtime: "jdk",
osArch: "amd64",
osName: "Linux",
}), }),
}; };
+238 -3
View File
@@ -1,8 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { spawnSync } from "node:child_process";
import { elf, fat, thinMach } from "../domain/binaryTarget.fixture.js"; import { elf, fat, thinMach } from "../domain/binaryTarget.fixture.js";
import { projectGhidraDoctorInspection } from "./GhidraDoctor.js"; import { projectGhidraDoctorInspection } from "./GhidraDoctor.js";
import { import {
ghidraInstallationDiagnostics,
ghidraJavaEnvironment, ghidraJavaEnvironment,
inspectGhidraInstallation, inspectGhidraInstallation,
type GhidraExecutableHeaderProbe, type GhidraExecutableHeaderProbe,
@@ -16,6 +18,14 @@ import {
ghidraJavaRequirement, ghidraJavaRequirement,
} from "./GhidraInstallationPolicy.js"; } 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 INSTALL = "/opt/ghidra";
const PROPERTIES = `${INSTALL}/Ghidra/application.properties`; const PROPERTIES = `${INSTALL}/Ghidra/application.properties`;
const HEADLESS = `${INSTALL}/support/analyzeHeadless`; const HEADLESS = `${INSTALL}/support/analyzeHeadless`;
@@ -32,6 +42,8 @@ const headerProbe = (bytes: Buffer): GhidraExecutableHeaderProbe => ({
}); });
const JAVA: GhidraJavaObservation = { const JAVA: GhidraJavaObservation = {
osArch: "amd64",
osName: "Linux",
version: "21.0.11", version: "21.0.11",
major: SUPPORTED_GHIDRA_JAVA_MAJOR, major: SUPPORTED_GHIDRA_JAVA_MAJOR,
home: "/usr/lib/jvm/jdk-21", home: "/usr/lib/jvm/jdk-21",
@@ -174,6 +186,11 @@ describe("Ghidra installation inspection", () => {
{ environment: {}, installDir: INSTALL, platform, architecture }, { environment: {}, installDir: INSTALL, platform, architecture },
host({ host({
executable: (path) => path === HEADLESS || path === decompiler, executable: (path) => path === HEADLESS || path === decompiler,
probeJava: () => ({
...JAVA,
osArch: architecture === "arm64" ? "aarch64" : "amd64",
osName: platform === "darwin" ? "Mac OS X" : "Linux",
}),
}), }),
), ),
).toMatchObject({ ).toMatchObject({
@@ -202,6 +219,7 @@ describe("Ghidra installation inspection", () => {
return { return {
...JAVA, ...JAVA,
home: "C:\\Java\\jdk-21", home: "C:\\Java\\jdk-21",
osName: "Windows 11",
}; };
}, },
}; };
@@ -721,7 +739,10 @@ describe("Ghidra FAT native decompiler admission", () => {
platform: "darwin", platform: "darwin",
architecture: "arm64", architecture: "arm64",
}, },
host({ executableHeader: () => headerProbe(bytes) }), host({
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
executableHeader: () => headerProbe(bytes),
}),
); );
expect(result.status).toBe(status); expect(result.status).toBe(status);
expect( expect(
@@ -739,7 +760,10 @@ describe("Ghidra FAT native decompiler admission", () => {
platform: "darwin", platform: "darwin",
architecture: "arm64", architecture: "arm64",
}, },
host({ executableHeader: () => headerProbe(bytes) }), host({
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
executableHeader: () => headerProbe(bytes),
}),
); );
expect(result.status).toBe("available"); expect(result.status).toBe("available");
expect( expect(
@@ -788,7 +812,14 @@ describe("Ghidra native decompiler admission", () => {
])("distinguishes $name", ({ options, executableHeader }) => { ])("distinguishes $name", ({ options, executableHeader }) => {
const result = inspectGhidraInstallation( const result = inspectGhidraInstallation(
options, 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(result.status).toBe("unavailable");
expect( expect(
@@ -849,6 +880,7 @@ describe("Ghidra native decompiler admission", () => {
architecture: "arm64", architecture: "arm64",
}, },
host({ host({
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
executableHeader: (path: string) => executableHeader: (path: string) =>
path === MAC_DECOMPILER ? headerProbe(bytes) : undefined, path === MAC_DECOMPILER ? headerProbe(bytes) : undefined,
}), }),
@@ -872,6 +904,7 @@ describe("Ghidra native decompiler admission", () => {
architecture: "arm64", architecture: "arm64",
}, },
host({ host({
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
executableHeader: (path: string) => executableHeader: (path: string) =>
path === MAC_DECOMPILER ? headerProbe(elf(2, 1, 62)) : undefined, 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();
}
});
});
+126
View File
@@ -203,6 +203,17 @@ interface GhidraInstallationObservation {
readonly javaCommand: string; readonly javaCommand: string;
/** Observed java.vm.name; omitted when the probe cannot establish VM identity. */ /** Observed java.vm.name; omitted when the probe cannot establish VM identity. */
readonly javaVmName?: string; 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[]; readonly checks: readonly GhidraInstallationCheck[];
} }
@@ -244,6 +255,17 @@ export interface GhidraJavaObservation {
readonly bits: number; readonly bits: number;
readonly runtime: "jdk" | "jre"; readonly runtime: "jdk" | "jre";
readonly vmName?: string; 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. */ /** A bounded executable-header probe: a small prefix plus the whole file size. */
@@ -324,6 +346,12 @@ export const inspectGhidraInstallation = (
...(coordinates.java?.vmName === undefined ...(coordinates.java?.vmName === undefined
? {} ? {}
: { javaVmName: coordinates.java.vmName }), : { javaVmName: coordinates.java.vmName }),
...(coordinates.java?.osArch === undefined
? {}
: { javaOsArch: coordinates.java.osArch }),
...(coordinates.java?.osName === undefined
? {}
: { javaOsName: coordinates.java.osName }),
checks, checks,
}; };
if (failed !== undefined) if (failed !== undefined)
@@ -555,6 +583,8 @@ export const ghidraInstallationDiagnostics = (
java_home: inspection.javaHome, java_home: inspection.javaHome,
java_version: inspection.javaVersion, java_version: inspection.javaVersion,
java_vm_name: inspection.javaVmName ?? null, java_vm_name: inspection.javaVmName ?? null,
java_os_arch: inspection.javaOsArch ?? null,
java_os_name: inspection.javaOsName ?? null,
platform: inspection.platform, platform: inspection.platform,
architecture: inspection.architecture, architecture: inspection.architecture,
checks: inspection.checks.map((check) => ({ checks: inspection.checks.map((check) => ({
@@ -632,6 +662,61 @@ const nativeDecompilerCheck = ({
architecture, architecture,
host.executableHeader?.(path), 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({ return installationCheck({
name: "native_decompiler", name: "native_decompiler",
passed: path !== null && compatibility?.status !== "incompatible", 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 = ( const versionCheck = (
properties: GhidraApplicationProperties | undefined, properties: GhidraApplicationProperties | undefined,
): GhidraInstallationCheck => { ): GhidraInstallationCheck => {
@@ -781,6 +903,8 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
const version = javaProperty(output, "java.version"); const version = javaProperty(output, "java.version");
const home = javaProperty(output, "java.home"); const home = javaProperty(output, "java.home");
const vmName = javaProperty(output, "java.vm.name"); const vmName = javaProperty(output, "java.vm.name");
const osArch = javaProperty(output, "os.arch");
const osName = javaProperty(output, "os.name");
const bits = Number.parseInt( const bits = Number.parseInt(
javaProperty(output, "sun.arch.data.model") ?? "0", javaProperty(output, "sun.arch.data.model") ?? "0",
10, 10,
@@ -791,6 +915,8 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
major: javaMajor(version), major: javaMajor(version),
home, home,
...(vmName === null ? {} : { vmName }), ...(vmName === null ? {} : { vmName }),
...(osArch === null ? {} : { osArch }),
...(osName === null ? {} : { osName }),
bits, bits,
runtime: executablePath( runtime: executablePath(
(process.platform === "win32" ? win32 : posix).join( (process.platform === "win32" ? win32 : posix).join(
+62 -2
View File
@@ -2,11 +2,43 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { execFileOutput } from "../process/ExecFileOutput.js"; 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. */ /** Launch coordinates obtained from Ghidra's own release configuration helper. */
export const ghidraJavaLaunch = async (options: { export const ghidraJavaLaunch = async (options: {
readonly platform: NodeJS.Platform; readonly platform: NodeJS.Platform;
readonly analyzeHeadlessPath: string; readonly analyzeHeadlessPath: string;
readonly javaHome: string; readonly javaHome?: string;
readonly javaVmName?: string; readonly javaVmName?: string;
readonly homeRoot: string; readonly homeRoot: string;
readonly tempRoot: string; readonly tempRoot: string;
@@ -21,7 +53,17 @@ export const ghidraJavaLaunch = async (options: {
}> => { }> => {
const supportRoot = dirname(options.analyzeHeadlessPath); const supportRoot = dirname(options.analyzeHeadlessPath);
const installRoot = dirname(supportRoot); 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 = [ const isolatedProperties = [
`-Duser.home=${options.homeRoot}`, `-Duser.home=${options.homeRoot}`,
`-Djava.io.tmpdir=${options.tempRoot}`, `-Djava.io.tmpdir=${options.tempRoot}`,
@@ -48,6 +90,9 @@ export const ghidraJavaLaunch = async (options: {
[...supportArguments, "-envvars"], [...supportArguments, "-envvars"],
{ {
env: environment, 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 }), ...(options.signal === undefined ? {} : { signal: options.signal }),
}, },
); );
@@ -65,11 +110,25 @@ export const ghidraJavaLaunch = async (options: {
if ((environment[key] ?? "") === "") if ((environment[key] ?? "") === "")
environment[key] = line.slice(separator + 1); 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( const configuredArguments = await execFileOutput(
command, command,
[...supportArguments, "-vmargs"], [...supportArguments, "-vmargs"],
{ {
env: environment, 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 }), ...(options.signal === undefined ? {} : { signal: options.signal }),
}, },
); );
@@ -83,6 +142,7 @@ export const ghidraJavaLaunch = async (options: {
) )
) )
throw new Error("Invalid Ghidra LaunchSupport JVM argument"); throw new Error("Invalid Ghidra LaunchSupport JVM argument");
rejectJvmPlatformOverrides(vmArguments);
return { return {
command, command,
environment, environment,
+41 -36
View File
@@ -172,6 +172,8 @@ export interface GhidraHeadlessLauncherOptions {
readonly mipsSoftFloat?: true; readonly mipsSoftFloat?: true;
/** Spawn seam for provider-boundary lifecycle tests. */ /** Spawn seam for provider-boundary lifecycle tests. */
readonly spawnProcess?: typeof spawnOwnedProviderProcess; 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. */ /** Launch Ghidra without copying scripts into or modifying its installation. */
@@ -303,43 +305,46 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
throw new GhidraLaunchError( throw new GhidraLaunchError(
"POSIX Ghidra requires its inspected JDK home", "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 = const command =
platform !== "win32" && this.options.javaHome !== undefined platform === "win32"
? await ghidraJavaLaunch({ ? { ...scriptCommand, environment }
platform, : inspectedJavaLaunch;
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 };
started = await (this.options.spawnProcess ?? spawnOwnedProviderProcess)({ started = await (this.options.spawnProcess ?? spawnOwnedProviderProcess)({
command: command.command, command: command.command,
arguments: command.arguments, arguments: command.arguments,
+28 -11
View File
@@ -37,12 +37,16 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js"; import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
const INSTALL = "/opt/ghidra_12.1.4_PUBLIC"; const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
const installationHost = (): GhidraInstallationHost => ({ const installationHost = (
platform: "linux", platform: NodeJS.Platform = "linux",
): GhidraInstallationHost => ({
platform,
architecture: "x64", architecture: "x64",
readText: () => "application.version=12.1.4\napplication.java.min=21\n", readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true, executable: () => true,
probeJava: () => ({ probeJava: () => ({
osArch: "amd64",
osName: platform === "win32" ? "Windows 11" : "Linux",
version: "21.0.11", version: "21.0.11",
major: 21, major: 21,
home: "/usr/lib/jvm/jdk-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"); expect(windows.inspectTargetSupport(target).status).toBe("unsupported");
}); });
it("discovers the exact installation once without launching Ghidra", () => { it("discovers the exact installation once without launching Ghidra", () => {
@@ -112,6 +116,8 @@ describe("Ghidra provider", () => {
probeJava: () => { probeJava: () => {
probeCount += 1; probeCount += 1;
return { return {
osArch: "amd64",
osName: "Linux",
version: "21.0.11", version: "21.0.11",
major: 21, major: 21,
home: "/usr/lib/jvm/jdk-21", home: "/usr/lib/jvm/jdk-21",
@@ -193,7 +199,7 @@ describe("Ghidra provider", () => {
describe("Ghidra raw-binary profile", () => { describe("Ghidra raw-binary profile", () => {
it("admits raw 6502 on Windows and binds its 16-bit language without mutation authority", async () => { 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 = { const target: BinaryTarget = {
path: "/tmp/6502.bin", path: "/tmp/6502.bin",
sha256: "a".repeat(64), sha256: "a".repeat(64),
@@ -260,7 +266,7 @@ describe("Ghidra raw-binary profile", () => {
expect(shifted.value.profile?.digest).not.toBe( expect(shifted.value.profile?.digest).not.toBe(
resolved.value.profile.digest, resolved.value.profile.digest,
); );
const windows = provider({ ...installationHost(), platform: "win32" }); const windows = provider(installationHost("win32"));
expect(windows.inspectTargetSupport(target).status).toBe("unsupported"); expect(windows.inspectTargetSupport(target).status).toBe("unsupported");
}); });
}); });
@@ -302,7 +308,7 @@ describe("Ghidra Mach-O slice support", () => {
describe("Ghidra platform support", () => { describe("Ghidra platform support", () => {
it("commits the PE role and managed classification to profile identity", async () => { 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 application = peTarget("x86_64");
const library = { const library = {
...application, ...application,
@@ -343,7 +349,7 @@ describe("Ghidra platform support", () => {
}); });
it("keeps Windows annotation mutation unavailable independently of native controls", () => { it("keeps Windows annotation mutation unavailable independently of native controls", () => {
const ghidra = provider({ ...installationHost(), platform: "win32" }); const ghidra = provider(installationHost("win32"));
expect( expect(
ghidra ghidra
.capabilities() .capabilities()
@@ -363,7 +369,7 @@ describe("Ghidra platform support", () => {
reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON, reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON,
}); });
onTestFinished(() => native.mockRestore()); onTestFinished(() => native.mockRestore());
const ghidra = provider({ ...installationHost(), platform: "win32" }); const ghidra = provider(installationHost("win32"));
const nativeApplication = peTarget("x86_64"); const nativeApplication = peTarget("x86_64");
for (const architecture of ["x86", "x86_64"] as const) for (const architecture of ["x86", "x86_64"] as const)
@@ -454,7 +460,7 @@ describe("Ghidra Windows PE fixture admission", () => {
await promisify(execFile)(process.execPath, [ await promisify(execFile)(process.execPath, [
join(root, "scripts/create-ghidra-windows-fixture.mjs"), 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 architecture of ["x86", "x86_64"] as const) {
for (const dll of [false, true]) { for (const dll of [false, true]) {
const path = join( const path = join(
@@ -627,7 +633,7 @@ describe("Ghidra client projection", () => {
expect(resolved.value.profile.parameters).toMatchObject({ expect(resolved.value.profile.parameters).toMatchObject({
language_id: "auto-from-header", language_id: "auto-from-header",
compiler_spec_id: "auto-default", 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", function_dossier_coverage: "observed-facets-and-call-relationships-v1",
}); });
@@ -864,7 +870,16 @@ describe("Ghidra relationship coverage", () => {
Promise.resolve( Promise.resolve(
ok( ok(
operation === "procedure_callees" 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: [] }, : { value: [], limitations: [] },
), ),
), ),
@@ -1818,6 +1833,8 @@ describe("compatible Ghidra builds", () => {
readText: () => readText: () =>
"application.version=12.1.2\napplication.java.min=21\napplication.java.max=\n", "application.version=12.1.2\napplication.java.min=21\napplication.java.max=\n",
probeJava: () => ({ probeJava: () => ({
osArch: "amd64",
osName: "Linux",
version: "27", version: "27",
major: 27, major: 27,
home: "/usr/lib/jvm/java-27-openjdk", home: "/usr/lib/jvm/java-27-openjdk",
-17
View File
@@ -2,7 +2,6 @@ import type {
CapabilityDescriptor, CapabilityDescriptor,
ProviderIdentity, ProviderIdentity,
} from "../application/AnalysisProvider.js"; } from "../application/AnalysisProvider.js";
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
import { import {
hasWindowsNativeAuthority, hasWindowsNativeAuthority,
windowsNativeAuthorityUnavailableReason, windowsNativeAuthorityUnavailableReason,
@@ -44,22 +43,6 @@ const stringInventoryLimitation =
const searchLimitation = 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."; "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. */ /** Limitation text for one admitted operation, including the common base. */
export const limitationsFor = (operation: string): readonly string[] => { export const limitationsFor = (operation: string): readonly string[] => {
const common = [ const common = [
+5 -2
View File
@@ -34,6 +34,7 @@ import {
} from "../domain/analysisErrorCore.js"; } from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { EvidenceLocation } from "../domain/evidence.js"; import type { EvidenceLocation } from "../domain/evidence.js";
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { err, ok, type Result } from "../domain/result.js"; import { err, ok, type Result } from "../domain/result.js";
@@ -86,7 +87,6 @@ import {
healthLimitations, healthLimitations,
windowsP0Limitations, windowsP0Limitations,
limitationsFor, limitationsFor,
relationshipCoverageFor,
} from "./GhidraProviderCapabilities.js"; } from "./GhidraProviderCapabilities.js";
import type { GhidraSessionError } from "./GhidraSessionError.js"; import type { GhidraSessionError } from "./GhidraSessionError.js";
import { import {
@@ -733,8 +733,8 @@ export const createGhidraProviderClient = (input: {
rawResult: JsonValue, rawResult: JsonValue,
observationLimitations: readonly string[] = [], observationLimitations: readonly string[] = [],
locations: readonly EvidenceLocation[] = [], locations: readonly EvidenceLocation[] = [],
relationshipCoverage?: RelationshipCoverage,
) => { ) => {
const relationshipCoverage = relationshipCoverageFor(operation);
return createAnalysisExecution(normalized, committedProfile.provider, { return createAnalysisExecution(normalized, committedProfile.provider, {
rawResult, rawResult,
locations, locations,
@@ -838,12 +838,14 @@ export const createGhidraProviderClient = (input: {
let normalized: JsonValue; let normalized: JsonValue;
let observationLimitations: readonly string[] = []; let observationLimitations: readonly string[] = [];
let observationLocations: readonly EvidenceLocation[] = []; let observationLocations: readonly EvidenceLocation[] = [];
let relationshipCoverage: RelationshipCoverage | undefined;
if (isGhidraFunctionOperation(operation)) { if (isGhidraFunctionOperation(operation)) {
const result = parseGhidraFunctionResult(operation, called.value); const result = parseGhidraFunctionResult(operation, called.value);
if (!result.ok) return result; if (!result.ok) return result;
normalized = result.value.value; normalized = result.value.value;
observationLimitations = result.value.limitations; observationLimitations = result.value.limitations;
observationLocations = result.value.locations ?? []; observationLocations = result.value.locations ?? [];
relationshipCoverage = result.value.relationshipCoverage;
} else { } else {
const result = parseGhidraInventoryResult(operation, called.value); const result = parseGhidraInventoryResult(operation, called.value);
if (!result.ok) return result; if (!result.ok) return result;
@@ -939,6 +941,7 @@ export const createGhidraProviderClient = (input: {
called.value, called.value,
observationLimitations, observationLimitations,
observationLocations, observationLocations,
relationshipCoverage,
), ),
); );
}, },
+123 -37
View File
@@ -11,6 +11,7 @@ import { silentLogger } from "../logger.js";
import { GhidraProvider } from "./GhidraProvider.js"; import { GhidraProvider } from "./GhidraProvider.js";
import { GhidraSessionError } from "./GhidraSessionError.js"; import { GhidraSessionError } from "./GhidraSessionError.js";
import { GHIDRA_SESSION_CAPABILITIES } from "./GhidraSessionValues.js"; import { GHIDRA_SESSION_CAPABILITIES } from "./GhidraSessionValues.js";
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
const TARGET: BinaryTarget = { const TARGET: BinaryTarget = {
path: "/tmp/ghidra-cache-fixture", path: "/tmp/ghidra-cache-fixture",
@@ -23,7 +24,7 @@ const TARGET: BinaryTarget = {
// Exercise the real provider descriptors, profile, input/output projection and // Exercise the real provider descriptors, profile, input/output projection and
// session cache; only the installed Ghidra process is replaced at its seam. // 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" }); const config = parseConfig({ GHIDRA_INSTALL_DIR: "/opt/ghidra" });
if (!config.ok) throw config.error; if (!config.ok) throw config.error;
const calls: string[] = []; const calls: string[] = [];
@@ -43,6 +44,8 @@ const fixture = (version = "12.1.4") => {
`application.version=${version}\napplication.java.min=21\n`, `application.version=${version}\napplication.java.min=21\n`,
executable: () => true, executable: () => true,
probeJava: () => ({ probeJava: () => ({
osArch: "amd64",
osName: "Linux",
version: "21.0.11", version: "21.0.11",
major: 21, major: 21,
home: "/usr/lib/jvm/jdk-21", home: "/usr/lib/jvm/jdk-21",
@@ -111,7 +114,13 @@ const fixture = (version = "12.1.4") => {
return ok( return ok(
operation === "procedure_pseudo_code" || operation === "procedure_pseudo_code" ||
operation === "analyze_function" operation === "analyze_function"
? { value, limitations: [] } ? {
value,
limitations: [],
...(operation === "analyze_function" && coverage !== undefined
? { relationship_coverage: coverage }
: {}),
}
: value, : value,
); );
}, },
@@ -131,42 +140,119 @@ const open = async (session: ReturnType<typeof fixture>["session"]) => {
expect((await session.openResolvedTarget(preview.value)).ok).toBe(true); expect((await session.openResolvedTarget(preview.value)).ok).toBe(true);
}; };
describe("Ghidra snapshot cache", () => { describe("Ghidra relationship coverage replay", () => {
it("refuses snapshots whose profile predates typed incoming references", async () => { it.each([
const initial = fixture(); undefined,
await open(initial.session); { status: "complete", diagnostics: ["Resolved static references only."] },
const snapshot = initial.session.exportAnalysisSnapshot(); { status: "partial", limitations: ["Instruction coverage was truncated."] },
if (!snapshot.ok) throw snapshot.error; { status: "unknown", reason: "Relationship inventory was unavailable." },
await initial.session.close(); {
const profile = snapshot.value.binding.analysis_profile; status: "partial",
expect(profile.parameters.instruction_references).toBe( limitations: ["Missing callers."],
"exact-address-incoming-v1", limited_directions: ["callers"],
); },
const legacy = { ] satisfies (RelationshipCoverage | undefined)[])(
...snapshot.value, "preserves relationship coverage %j through live execution, cache and snapshot replay",
binding: { async (coverage) => {
...snapshot.value.binding, const initial = fixture("12.1.4", coverage);
analysis_profile: createAnalysisProfile( await open(initial.session);
profile.provider, const parameters = { procedure: "0x1000" };
Object.fromEntries( for (let i = 0; i < 2; i++) {
Object.entries(profile.parameters).filter( const execution = await initial.session.execute(
([key]) => key !== "instruction_references", "analyze_function",
), parameters,
), );
), if (!execution.ok) throw execution.error;
}, expect(execution.value.relationshipCoverage).toEqual(coverage);
}; }
const replay = fixture(); expect(initial.calls).toEqual(["analyze_function"]);
const imported = replay.session.importAnalysisSnapshot(legacy); const snapshot = initial.session.exportAnalysisSnapshot();
if (!imported.ok) throw imported.error; if (!snapshot.ok) throw snapshot.error;
expect(await replay.session.previewTarget(TARGET)).toMatchObject({ await initial.session.close();
ok: false, const replay = fixture();
error: { _tag: "EvidenceIntegrityError" }, expect(replay.session.importAnalysisSnapshot(snapshot.value).ok).toBe(
}); true,
expect(replay.calls).toEqual([]); );
await replay.session.close(); 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([ it.each([
{ operation: "analyze_function", parameters: { procedure: "0x1000" } }, { operation: "analyze_function", parameters: { procedure: "0x1000" } },
{ {
+10 -3
View File
@@ -25,7 +25,7 @@ import {
import type { import type {
PendingDirectory, PendingDirectory,
ReferenceSourceEntry, ReferenceSourceEntry,
ReferenceSourceEntryKind, ReferenceSourceExclusionKind,
ReferenceSourceResult, ReferenceSourceResult,
TraversalState, TraversalState,
} from "./ReferenceSourceReaderTypes.js"; } from "./ReferenceSourceReaderTypes.js";
@@ -201,6 +201,13 @@ const processEntry = async (
const metadata = await readMetadata(absolute); const metadata = await readMetadata(absolute);
if (isAborted(state.signal)) return { ok: false, error: cancelled() }; if (isAborted(state.signal)) return { ok: false, error: cancelled() };
if (!metadata.ok) { 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)); state.entries.push(entryFailure(path, "unknown", "io", metadata.message));
return { ok: true, value: undefined }; return { ok: true, value: undefined };
} }
@@ -371,10 +378,10 @@ const readSymlinkTarget = async (
const applyExclusion = ( const applyExclusion = (
shouldExclude: shouldExclude:
| ((path: string, kind: ReferenceSourceEntryKind) => boolean) | ((path: string, kind: ReferenceSourceExclusionKind) => boolean)
| undefined, | undefined,
path: string, path: string,
kind: ReferenceSourceEntryKind, kind: ReferenceSourceExclusionKind,
): { readonly ok: true; readonly value: boolean } | { readonly ok: false } => { ): { readonly ok: true; readonly value: boolean } | { readonly ok: false } => {
try { try {
return { ok: true, value: shouldExclude?.(path, kind) === true }; return { ok: true, value: shouldExclude?.(path, kind) === true };
@@ -29,6 +29,9 @@ const FILESYSTEM_ERROR_CODES = new Set([
"EISDIR", "EISDIR",
"ELOOP", "ELOOP",
"EMFILE", "EMFILE",
// A traversed child can exceed the platform's absolute path limit even
// though the selected root and every component are valid.
"ENAMETOOLONG",
"ENFILE", "ENFILE",
"ENOENT", "ENOENT",
"ENOTDIR", "ENOTDIR",
@@ -44,6 +47,8 @@ export const filesystemFailureDetail = (
): string | undefined => { ): string | undefined => {
if (!(cause instanceof Error)) return undefined; if (!(cause instanceof Error)) return undefined;
const code: unknown = Reflect.get(cause, "code"); 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) return typeof code === "string" && FILESYSTEM_ERROR_CODES.has(code)
? `${operation}: ${cause.message}` ? `${operation}: ${cause.message}`
: undefined; : undefined;
+8 -2
View File
@@ -10,11 +10,17 @@ export type ReferenceSourceEntryKind =
| "symlink" | "symlink"
| "other"; | "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 { export interface ReferenceSourceReaderOptions {
readonly signal?: AbortSignal; readonly signal?: AbortSignal;
readonly shouldExclude?: ( readonly shouldExclude?: (
path: string, path: string,
kind: ReferenceSourceEntryKind, kind: ReferenceSourceExclusionKind,
) => boolean; ) => boolean;
} }
@@ -84,7 +90,7 @@ export type TraversalState = {
readonly signal?: AbortSignal; readonly signal?: AbortSignal;
readonly shouldExclude?: ( readonly shouldExclude?: (
path: string, path: string,
kind: ReferenceSourceEntryKind, kind: ReferenceSourceExclusionKind,
) => boolean; ) => boolean;
readonly entries: ReferenceSourceEntry[]; readonly entries: ReferenceSourceEntry[];
readonly pending: PendingDirectory[]; 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 { join } from "node:path";
import { expect } from "vitest"; 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( cliTest(
"rejects a file URL path field without resolving an invented local path", "rejects a file URL path field without resolving an invented local path",
async ({ cli }) => { async ({ cli }) => {
+40 -22
View File
@@ -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 () => { it.each([
const invalidSummary = { "not-a-dex-descriptor",
...summary, "(Lbad(name;)V",
methods: summary.methods.map((method) => ({ "(Lbad)name;)V",
...method, "()Lbad(name;",
dex_descriptor: "not-a-dex-descriptor", "()Lbad)name;",
})), ])(
}; "rejects malformed DEX prototype %s at the adapter boundary",
await expect( async (descriptor) => {
analyzeJadxRequest( const invalidSummary = {
{ ...summary,
json: () => Promise.resolve(invalidSummary), methods: summary.methods.map((method) => ({
text: () => Promise.reject(new Error("unexpected text call")), ...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); 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"])( it.each(["analyze-javascript-application", "analyze"])(
"%s carries the integrity policy into shared JavaScript analysis", "%s carries the integrity policy into shared JavaScript analysis",
async (command) => { async (command) => {
@@ -46,3 +59,48 @@ it("rejects the JavaScript integrity policy on binary-only inspection", async ()
stdout: expect.stringContaining("Unknown flag: --integrityPolicy"), 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", "kimi",
"vscode", "vscode",
"trae", "trae",
"workbuddy",
"grok_build", "grok_build",
"omp", "omp",
"pi", "pi",
@@ -55,6 +55,8 @@ const host: GhidraInstallationHost = {
readText: () => "application.version=12.1.3\napplication.java.min=21\n", readText: () => "application.version=12.1.3\napplication.java.min=21\n",
executable: () => true, executable: () => true,
probeJava: () => ({ probeJava: () => ({
osArch: "amd64",
osName: "Linux",
version: "21.0.12", version: "21.0.12",
major: 21, major: 21,
home: "/jdk-21", home: "/jdk-21",
@@ -133,3 +133,88 @@ describe("reference import native entry failure details", () => {
).toMatchObject({ kind: "file", content_state: "hashed", limitations: [] }); ).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"; } from "../../../src/application/SetupClientConfiguration.js";
import { skillDestinations } from "../../../src/application/SetupSkill.js"; import { skillDestinations } from "../../../src/application/SetupSkill.js";
import { supportedClients } from "../../../src/application/SupportedClients.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 { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.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", () => { describe("ZCode fallback admission", () => {
it.each([{ PORT: 123 }, { REA_MCP_INPUT_SCHEMA_PROFILE: "unknown" }])( it.each([{ PORT: 123 }, { REA_MCP_INPUT_SCHEMA_PROFILE: "unknown" }])(
"refuses malformed fallback REA environment %j before native writes", "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()]);
}
});
+21 -11
View File
@@ -15,9 +15,17 @@ import {
type GhidraFunctionOperation, type GhidraFunctionOperation,
} from "../../../src/ghidra/GhidraFunctionValues.js"; } from "../../../src/ghidra/GhidraFunctionValues.js";
it.each([true, false])( it.each([
"compares observed empty Ghidra facets while preserving pseudocode availability (%s)", { pseudocodeAvailable: true, reportsCoverage: true },
async (pseudocodeAvailable) => { { 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()); const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
Object.assign(dossier, { Object.assign(dossier, {
comments: [], comments: [],
@@ -47,7 +55,13 @@ it.each([true, false])(
if (operation !== "analyze_function") if (operation !== "analyze_function")
throw new Error(`Unexpected operation: ${operation}`); throw new Error(`Unexpected operation: ${operation}`);
return Promise.resolve( 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, harness.session,
analyzed.structuredContent, analyzed.structuredContent,
); );
expect(evidence.relationship_coverage).toMatchObject({ expect(evidence.relationship_coverage).toEqual(coverage ?? null);
status: "complete",
});
expect(evidence.normalized_result).toEqual(dossier); expect(evidence.normalized_result).toEqual(dossier);
const reply = await harness.mcp.callTool({ const reply = await harness.mcp.callTool({
name: "compare_functions", name: "compare_functions",
@@ -86,7 +98,7 @@ it.each([true, false])(
pseudocode: pseudocodeAvailable ? "unchanged" : "unknown", pseudocode: pseudocodeAvailable ? "unchanged" : "unknown",
assembly: "unchanged", assembly: "unchanged",
comments: "unchanged", comments: "unchanged",
calls: "unchanged", calls: reportsCoverage ? "unchanged" : "unknown",
references: "unchanged", references: "unchanged",
strings_names: "unchanged", strings_names: "unchanged",
cfg: "unchanged", cfg: "unchanged",
@@ -96,9 +108,7 @@ it.each([true, false])(
evidence.parameters, evidence.parameters,
); );
if (!replay.ok) throw replay.error; if (!replay.ok) throw replay.error;
expect(replay.value.relationshipCoverage).toEqual( expect(replay.value.relationshipCoverage).toEqual(coverage);
evidence.relationship_coverage,
);
expect(harness.calls).toEqual(["analyze_function"]); expect(harness.calls).toEqual(["analyze_function"]);
} finally { } finally {
await harness.close(); await harness.close();
+2
View File
@@ -106,6 +106,8 @@ export const ghidraInstallationHost = (): GhidraInstallationHost => ({
readText: () => "application.version=12.1.4\napplication.java.min=21\n", readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true, executable: () => true,
probeJava: () => ({ probeJava: () => ({
osArch: "amd64",
osName: "Linux",
version: "21.0.11", version: "21.0.11",
major: 21, major: 21,
home: "/usr/lib/jvm/jdk-21", home: "/usr/lib/jvm/jdk-21",
+2
View File
@@ -102,6 +102,8 @@ const installationHost: GhidraInstallationHost = {
readText: () => "application.version=12.1.4\napplication.java.min=21\n", readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true, executable: () => true,
probeJava: () => ({ probeJava: () => ({
osArch: "amd64",
osName: "Linux",
version: "21.0.11", version: "21.0.11",
major: 21, major: 21,
home: "/fixture/jdk-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.stderr.write("failed " + probe + "\\n");
process.exitCode = 3; process.exitCode = 3;
} else if (probe === "-envvars") { } 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 { } 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 }, { 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 () => { it("bounds retained UTF-8 warnings and reports original byte counts", async () => {
const { session, launcher } = await fixture("large"); const { session, launcher } = await fixture("large");
const launched = await launcher.launch(session); 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", "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true, executable: () => true,
probeJava: () => ({ probeJava: () => ({
osArch: process.arch === "arm64" ? "aarch64" : "amd64",
osName:
process.platform === "darwin"
? "Mac OS X"
: process.platform === "win32"
? "Windows 11"
: "Linux",
version: "21.0.12", version: "21.0.12",
major: 21, major: 21,
home: "/fixture/jdk", home: "/fixture/jdk",
@@ -524,6 +524,13 @@ describe("Ghidra Windows launcher runtime", () => {
), ),
javaHome: "C:\\Java\\jdk-21", javaHome: "C:\\Java\\jdk-21",
bridgeScriptPath: join(parent, "ReaGhidraBridge.java"), 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, spawnProcess,
}); });
const launched = await launcher.launch({ const launched = await launcher.launch({
@@ -569,6 +576,13 @@ describe("Ghidra Windows launcher runtime", () => {
platform: "win32", platform: "win32",
javaHome, javaHome,
bridgeScriptPath: "/package/bridge/ReaGhidraBridge.java", 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({ const launched = await launcher.launch({
runtimeRoot, runtimeRoot,
@@ -12,6 +12,8 @@ import ghidra.program.model.listing.FlowOverride;
import ghidra.program.model.listing.Function; import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.Instruction; import ghidra.program.model.listing.Instruction;
import ghidra.program.model.symbol.SourceType; import ghidra.program.model.symbol.SourceType;
import ghidra.program.model.symbol.Reference;
import ghidra.program.model.symbol.RefType;
import java.io.File; import java.io.File;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.nio.file.Files; import java.nio.file.Files;
@@ -20,9 +22,12 @@ import java.util.HexFormat;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.ArrayList;
/** Injects reported analysis flags into imported ELF/Mach-O functions; never executes target code. */ /** Injects reported analysis flags into imported ELF/Mach-O functions; never executes target code. */
public class ReaNoReturnProbe extends GhidraScript { 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 JsonArray checks = new JsonArray();
private final Map<Function, Address> originalEnds = new LinkedHashMap<>(); private final Map<Function, Address> originalEnds = new LinkedHashMap<>();
private final Map<Function, String> originalSignatures = new LinkedHashMap<>(); private final Map<Function, String> originalSignatures = new LinkedHashMap<>();
@@ -85,6 +90,114 @@ public class ReaNoReturnProbe extends GhidraScript {
return instance; 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 @Override
public void run() throws Exception { public void run() throws Exception {
String[] args = getScriptArgs(); String[] args = getScriptArgs();
@@ -164,6 +277,8 @@ public class ReaNoReturnProbe extends GhidraScript {
Object output = pseudocode.invoke(bridge, query); Object output = pseudocode.invoke(bridge, query);
require("recovered_pseudocode", output instanceof JsonObject value && require("recovered_pseudocode", output instanceof JsonObject value &&
value.get("value").getAsString().contains("rea_after_call")); value.get("value").getAsString().contains("rea_after_call"));
nonCallInstructionReference(bridge);
knownInteriorCall(bridge);
} finally { } finally {
((ghidra.app.decompiler.DecompInterface) field.get(bridge)).dispose(); ((ghidra.app.decompiler.DecompInterface) field.get(bridge)).dispose();
} }