Compare commits

..
Author SHA1 Message Date
Cursor AgentandGamerDubz bd9405b1b5 feat(cursor): ship a Marketplace plugin with the skill and pinned MCP server
Cursor Marketplace installs are Git checkouts, so the plugin manifest
points at a synced copy of the authored skill and starts
npx -y rea-agents@<release> mcp. Release Please and the release
checkpoint keep that pin aligned with the package version.

The manifest, skill, and icon checks follow the local plugin bundle
contract from #7619, adapted for a checkout that has no dist/.

Co-authored-by: GamerDubz <[email protected]>
2026-11-10 21:03:26 +00:00
395 changed files with 11485 additions and 12633 deletions

No files matched your search

@@ -17,9 +17,9 @@ readiness or provider commands.
Installing this skill supplies instructions; it does not register the REA MCP
server, install analysis engines, or add tools to an already-running agent.
The exception is a Claude Code or Codex plugin install: the plugin bundles
this skill and registers its own `rea` server, outside the state that
`rea setup` and `rea doctor` manage.
The exception is a Claude Code, Codex, or Cursor Marketplace plugin install:
the plugin bundles this skill and registers its own `rea` server, outside the
state that `rea setup` and `rea doctor` manage.
If REA tools are available and their registration is not known to be stale,
proceed directly to the target. Do not run diagnostics or setup before every
@@ -118,19 +118,22 @@ is not a recommendation to change it. Do not invent a setting. Host memory and
## REA installed as a client plugin
A Claude Code or Codex plugin owns its `rea` server and this skill. Doctor's
`--client` and `--skill` checks and `rea setup` read and write only
setup-owned state, so for a plugin-only install they report `missing`, and
applying setup would add a second `rea` server. If this skill was loaded from
a plugin, or the client lists a `rea` plugin, do not prepare a setup plan for
missing tools. Instead:
A Claude Code, Codex, or Cursor Marketplace plugin owns its `rea` server and
this skill. Doctor's `--client` and `--skill` checks and `rea setup` read and
write only setup-owned state, so for a plugin-only install they report
`missing`, and applying setup would add a second `rea` server. If this skill
was loaded from a plugin, or the client lists a `rea` plugin, do not prepare a
setup plan for missing tools. Instead:
- Check that the plugin is installed and enabled with the client's own
listing: `claude plugin list` and `/mcp` in Claude Code, `codex plugin list`
in Codex.
in Codex, and Cursor's plugin settings for a Marketplace install.
- Restart or reconnect the client, then read its launch error for the `rea`
server if tools are still absent. The plugin starts its bundled runtime; a missing build or version mismatch
requires a reinstall, or a fresh build when developing from source.
server if tools are still absent. Claude Code and Codex start the plugin's
bundled runtime; a missing build or version mismatch requires a reinstall,
or a fresh build when developing from source. A Cursor Marketplace install
starts `npx` at the version pinned in the plugin's `mcp.json`. A failed
download needs network access to that published version.
- Check analysis engines through the connected server's
`binary_session.result.tool_availability`. `rea doctor --provider <id> --json`
from a shell reports that shell's environment, which can differ from the
+20
View File
@@ -0,0 +1,20 @@
{
"name": "rea",
"version": "6.8.0",
"description": "Reverse engineer anything with agents, from app behavior down to native binaries.",
"author": {
"name": "morluto",
"url": "https://github.com/morluto"
},
"homepage": "https://rea.tools/",
"repository": "https://github.com/morluto/rea",
"license": "MIT",
"keywords": [
"reverse-engineering",
"binary-analysis",
"decompiler",
"mcp-server"
],
"skills": ".cursor-plugin/skills",
"logo": "assets/rea-plugin.png"
}
@@ -0,0 +1,603 @@
---
name: reverse-engineer-anything
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA, and build agent environments (AgentEnv bundles) of web applications from a live instance or a capture, and of Linux desktop apps with a task from a request. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
metadata:
version: "62"
---
# REA
Use REA when a claim depends on a shipped binary or package, decompilation,
requested runtime evidence, or comparison with
behavior not established by available source. For ordinary analysis of a
complete source repository, use normal repository tools and do not run REA
readiness or provider commands.
## Connect only when needed
Installing this skill supplies instructions; it does not register the REA MCP
server, install analysis engines, or add tools to an already-running agent.
The exception is a Claude Code, Codex, or Cursor Marketplace plugin install:
the plugin bundles this skill and registers its own `rea` server, outside the
state that `rea setup` and `rea doctor` manage.
If REA tools are available and their registration is not known to be stale,
proceed directly to the target. Do not run diagnostics or setup before every
investigation. Use the connected server's actual tool list and input schemas;
a skill installed from repository main may describe capabilities absent from
an older npm release. Keep complete inline Evidence when that server does not
advertise retained references.
If tools are absent, a call is blocked by the client, or a required provider is
unavailable, read [connection and recovery](references/connection-and-recovery.md).
Diagnose the observed blocker, continue useful supported work, and resume the
original task after repair. Client approval, shell PATH, registration and engine
availability have different remedies; avoid repeating setup for all of them.
If the host times out without a REA result, do not repeat the unchanged call or
assume work stopped. Read the recovery guide's deadline distinction before retrying.
If the MCP connection drops, retry that call once only when it is safe to
repeat or known not to have started. If it may already have changed the
target, or it fails again, tell the user and ask them to restart the MCP
client. If REA could not start its connection, retry once, then tell the user
and ask them to restart the MCP client. Do not retry a startup failure that
returns the same unchanged result. For plugin installs, follow
[plugin recovery](references/connection-and-recovery.md#rea-installed-as-a-client-plugin).
For setup-owned registrations, run `rea doctor` when REA could not start
or tools stay missing after that restart. For `cleanup_incomplete`, a retryable
capacity limit, or `configuration_invalid`, follow the recovery guide's ladder
before asking for a restart. Read that guide before a second repair attempt.
Choose the installation owner before repairing registration. Plugin installs use
[plugin recovery](references/connection-and-recovery.md#rea-installed-as-a-client-plugin);
only setup-owned registrations use the guide's
[registration repair plan](references/connection-and-recovery.md#repair-registration-when-needed).
A plugin-only doctor result of `missing` does not call for `rea setup`.
REA setup never installs or upgrades Node.js, npm, Homebrew, Java, Ghidra, IDA,
JADX, Kuna, Binwalk, or Unblob. Use existing prerequisites; do not install unrelated
software to repair MCP registration. For an unsupported client, use manual stdio
registration with an existing installation’s absolute runtime and entry script, or continue through the CLI.
A concrete CLI fallback for an operator-supplied JavaScript tree or ASAR is:
```bash
npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json
```
No MCP registration or native engine is required. Read the returned Evidence,
graph, limitations, and unknowns with the same care as an MCP result; the CLI
returns the Evidence record directly. This fallback does not establish that
MCP is configured. Native CLI tasks still require their selected engine.
Prefer an existing `rea` CLI. When none is installed and REA package acquisition
is within the authorized scope, use `npx -y rea-agents@latest` in its place from a
trusted directory outside the target project, with absolute target paths. Managed
setup binds the current installation directly; retain its runtime and package
cache entry, or restore that installation before repairing its registration.
For setup-owned registration, configuration repair starts with a read-only setup plan; show paths, backups and
skill replacement before applying authorized changes. Preserve existing approval
and target-execution boundaries.
## Route the target first
Choose the first tool from the target the user supplied. Use `open_binary` for
active-target native or archive workflows; target-free tools take their own
explicit path or endpoint and do not need it.
- ASAR or extracted JavaScript/Electron tree:
`analyze_javascript_application`.
- Archive/package member inventory (ZIP/APK/IPA/MSIX/AppX, DMG, or a PyInstaller
executable): `open_binary` with the supplied local path; use `inspect_artifact`
when its graph and findings help answer the question. A PyInstaller CArchive
is the application layer; the bootloader is only the native prefix. Start with
`detail: "summary"` for large inventories, then use retained inventory views
for selected member fields/pages. A complete small inventory can cost less
than extra calls and schemas. Runtime options are metadata, not files.
Compact delivery still inventories and hashes members. See the
[Python packaged applications guide](https://github.com/morluto/rea/blob/main/docs/python-packaged-applications.md).
- Bun `--compile` executable: `open_binary`, then `inspect_artifact`; the Bun
module graph is the application layer. Extract stored modules and analyze the
JavaScript. See [Bun standalone executables](https://github.com/morluto/rea/blob/main/docs/bun-standalone.md).
- Deno `deno compile` executable: `open_binary`, then `inspect_artifact`; the
embedded virtual filesystem is the application layer. Extract stored files and
analyze compatible JavaScript. Environment-file values and CA certificate bytes
are retained in local Evidence with their counts and byte lengths. The npm
snapshot length is reported and not decoded, and the executable is not run. See
[Deno compile executables](https://github.com/morluto/rea/blob/main/docs/deno-standalone.md).
- Apple app plist, Interface Builder, asset catalog, or keyed archive resources:
`open_bundle_resources`, then the selected resource tool. This does not
require `CFBundleExecutable` or a deep provider. Resource sessions have no
executable digest or binary snapshot; use `open_binary` for native code.
- Android APK code, classes, methods, or incoming references:
`inspect_android_package`, then focused Android tools when advertised.
Archive member inventory still uses the archive route above.
- Managed PE/CLI assembly: `inspect_managed_artifact`.
- Firmware image: `inspect_firmware_regions` when advertised. Use
`extract_firmware` when extraction is requested, with a caller-selected new
absolute output directory. These tools use caller-supplied Binwalk/Unblob on
Linux; see the [firmware guide](https://github.com/morluto/rea/blob/main/docs/guides/firmware/firmware-analysis.md).
- ESP32-family app or bootloader image (`0xE9` magic): `inspect_artifact`
without a provider; see the ESP section of `references/native-and-artifacts.md`.
- .NET NativeAOT PE/ELF: native Ghidra analysis. Native code can yield pseudocode;
consult the NativeAOT workflow in `references/native-and-artifacts.md` for
optional metadata recovery and its supported host/layout boundary.
- Native program behavior, live calls, instrumentation, or command-run effects:
choose the matching [native runtime workflow](references/runtime-observation.md#native-runtime-questions).
`open_binary` and `trace_native_values` establish static facts; they do not
observe execution. Runtime tools have distinct launch, attach, mutation and
cleanup effects, and do not require a preliminary static session unless their
own contract says so.
- User-owned browser page already open: `list_browser_targets`.
- Passive traffic capture of a SPA: the default scope is the page's own origin,
and data APIs usually live on another origin. Discover API origins from
absolute URLs in loaded bundles or script text via JavaScript analysis tools
(another passive capture without those origins still scopes to the page
origin, and `completeness.excluded` counts lack URLs). Pass both the page
origin and those API origins in `allowed_origins` — the list replaces the
default scope — and read `completeness.excluded` before reporting that an
API is absent. For long captures with body shapes, use
`export_evidence_bundle` instead of expecting an inline result.
- Browser action the agent must perform (click, type, navigate):
`capture_browser_scenario` with declared actions. See
[runtime observation](references/runtime-observation.md) for connect/launch
and the focused follow-up browser tools.
- Agent environment (AgentEnv bundle) of a web application: read
[environments](references/environments.md) before recording; it records with
`capture_browser_scenario` and writes the bundle with `export_environment_bundle`.
- Task for a desktop application's exported environment, from a
`capture-desktop-application` folder: read
[desktop application tasks](references/environments.md#tasks-for-a-desktop-application-environment).
- Environment of a Linux desktop app with a task, from a request such as
"build me an Inkscape environment where the agent changes a poster's
headline": read [desktop builds](references/desktop-build.md). You drive the
app on a build computer from `start_build_computer`, then export and witness.
- Fiddler Everywhere traffic or a native `.saz` capture: read the
[Fiddler workflow](references/runtime-observation.md#fiddler-everywhere-traffic-debugging).
Inspect saved SAZ with `inspect_web_network_capture` and `format: "saz"`;
connect Everywhere's separate MCP server for live traffic.
- Retained HAR/SAZ/native mitmproxy/aioquic qlog capture: `inspect_web_network_capture`. Select
original record ordinals when useful; inspect producer fields and byte/number
sidecars without fetching recorded URLs or claiming live attribution. HAR/SAZ/qlog
old-generation heap is `REA_CAPTURE_DECODER_OLD_GENERATION_MIB` (default 192 MiB,
floor 8, maximum 16384), set where REA starts. Host memory and `NODE_OPTIONS` do not
raise it. See
[historical captures](https://github.com/morluto/rea/blob/main/docs/guides/web/web-network-captures.md) for the exact upstream
profile and credential exclusions. Qlog support starts with aioquic 1.5.0
JSON 0.3; stream coverage and endpoint loss declarations do not recover bodies
or establish network loss. Prefer detail summary, then kind qlog trace/stream
pages; select one trace, stream ID and direction for contribution/range pages.
Facts retain all-recorded-events-before-selection scope. Other qlog layouts
and PCAP correlation are unsupported.
- User-owned Electron runtime already open: `list_electron_targets`. Before an electron-main Inspector attempt, check packaged fuses with `inspect_electron_fuses`; disabled `EnableNodeCliInspectArguments` means the app ignores `--inspect`.
- Packet-field discovery: `inspect_packet_fields` searches installed protocols
and field names/types before choosing fields for `inspect_packet_capture`.
Query/page the relevant records; registrations do not establish observed traffic.
- Recorded packet captures: `inspect_packet_capture` with selected TShark fields,
display filters and decode-as rules. Requires Linux, util-linux prlimit and
TShark 4.x on an absolute `PATH` entry or via the optional absolute
`REA_TSHARK_COMMAND` override; see
[capture guide](https://github.com/morluto/rea/blob/main/docs/guides/data/packet-captures.md).
Packet limits count input packets before filtering. Output defaults to 50 matching
frames; use `match_offset` / `match_limit` for later pages, or explicit null
limit for complete matching output. Preserve the source digest across pages.
This does not perform live capture.
For address/DNS/SNI/protocol summaries use `inspect_pcap_capture` with MCP
detail summary, then retained kind pcap pages. Requires Wireshark 4.x tshark
and capinfos, with Linux prlimit or Windows Job Objects; see
[packet summaries](https://github.com/morluto/rea/blob/main/docs/guides/data/pcap-analysis.md).
Select an exact DNS question for address pages; page exhaustion does not prove
network completeness. This reads recordings and does not acquire traffic.
- Decoder discovery: `inspect_signal_decoders` searches installed decoder IDs
or selected decoder help, including channels and options. Query and page the
relevant details before choosing settings; this does not scan hardware.
- Recorded logic/signal traces: `decode_signal_recording` with explicit decoder,
channel and protocol settings. On Linux with util-linux prlimit, supply sigrok-cli 0.7.2 through
`REA_SIGROK_COMMAND`; see [recording guide](https://github.com/morluto/rea/blob/main/docs/guides/signals/signal-recordings.md).
Output defaults to 50 literal lines; select annotation classes and page with
`line_offset` / `line_limit`, or explicit null for all remaining output.
Window counts are display lines, not annotations or samples. Preserve source digests across pages.
This reads existing files; it does not acquire hardware signals.
- Explicit local WebAssembly artifact: `inspect_wasm_artifact`. Supply WABT 1.0.42
through `REA_WABT_BIN_DIRECTORY`; retain exact byte/tool identity and decoded WAT.
Select JavaScript glue and candidate paths explicitly and preserve URL/path
ambiguity. No implicit fetch or target execution; WAT is not original source.
See the [WASM guide](https://github.com/morluto/rea/blob/main/docs/guides/wasm/wasm-artifacts.md).
- Explicit local Android APK (or ZIP/AAB) signing: `inspect_apk_signature`.
Prefer its summary or selected signer/digest/lineage page; retain complete
Evidence and use `inspect_analysis_view` kind `apk-signature` for follow-ups.
Reports which schemes are present (v1 JAR, v2, v3, v3.1), each signer's
certificate SHA-256 fingerprint (v1 included, from its PKCS#7 block), v3 key-rotation lineage, source stamp, and
whether each signed digest still matches the bytes (self-consistency). This
is identity and integrity, not a trust or authenticity verdict.
See the [APK signature guide](https://github.com/morluto/rea/blob/main/docs/apk-signature.md).
- Explicit local EVM bytecode carrier: `inspect_evm_interface` with caller-selected
`raw` or `hex` encoding. The owned worker deadline is `timeout_ms` (default
30000 ms, range 1..2147483647, next call). It does not raise the fixed byte,
address-space, or CPU allowances. Preserve carrier/decoded digests and treat selectors,
argument strings and mutability as inferred candidates; this performs no chain
lookup or target execution. For the complete inferred interface without hex or
duplicate stdout, discover support and select `detail: "summary"`; complete
Evidence stays retained under its original identity. Use the returned
`parent_evidence_id` for exact function or `bytecode_hex`/stdout/stderr pages.
Candidate coverage does not establish interface completeness. See the
[EVM bytecode guide](https://github.com/morluto/rea/blob/main/docs/guides/evm/evm-bytecode.md).
- Embedded Go compiler, module, build-setting metadata, or static `(ptr, len)`
string literals: `inspect_go_binary` with the explicit local file (pass
`include_string_literals: true` and optional `max_string_literals` for bounded
inferred static string-header recovery, and page retained rows with
`inspect_analysis_view` kind `go` collection `string_literals`). This built-in
target-free inspection requires no Go installation or disassembler. Preserve
raw module bytes and source ranges; absent metadata does not prove a binary is
not Go, static string recovery excludes compiler-inlined stack or immediate
strings, and unparsed text records remain explicit. See the
[Go binary guide](https://github.com/morluto/rea/blob/main/docs/guides/native/go-binaries.md).
- Embedded CUDA/HIP code objects: `inspect_gpu_code_objects`; inventory targets
first, then decode selected payloads and query retained GPU Evidence.
- Bundled GGUF or safetensors model declarations: `inspect_model_artifact`
with the explicit local file. Default header hashing leaves full-file identity
unknown; `hash: "full"` additionally reads all weights for SHA-256. MCP returns
retained summaries; optional `view` selects one page in the initial call. Use
`inspect_analysis_view` kind `model-artifact` for
batched metadata keys, tensor names or type totals, selected fields and pages.
Preserve header-scoped indices and array preview omissions; do not infer model
behavior from declared architecture or tensor types. See the
[model file guide](https://github.com/morluto/rea/blob/main/docs/model-artifacts.md).
- Unity IL2CPP names, member owners and string literals from `global-metadata.dat`:
`inspect_il2cpp_metadata` with the explicit local file; MCP defaults to compact
counts. Query sections, images, types, methods, fields, properties, events,
parameters or string_literals through `inspect_analysis_view`
with the returned `parent_evidence_id`, using exact filters, batch indices and
selected fields. Parameters accept `method_indices`, avoiding a scan of all
parameter pages. Missing collections in historical Evidence remain unknown.
Versions 24, 27, 29, 31 and 39; no Unity, IL2CPP
binary or disassembler is run. Unrecognized magic (possibly encrypted) and
other versions are unsupported. Optional matching `binary_path` enables static native addresses and type references; query `method_addresses` with indices or addresses/states, preserving every shared-body method and both artifact identities. Without a linked binary native counts remain unknown. Byte-pattern fallback is inferred; no runtime behavior or same-build pairing is proved. See the
[IL2CPP metadata guide](https://github.com/morluto/rea/blob/main/docs/il2cpp-metadata.md).
- Embedded cargo-auditable Rust dependency metadata: `inspect_rust_binary`
with the explicit local ELF, PE, or thin Mach-O file. This built-in
target-free inspection requires no Rust installation or disassembler. Preserve
package order, dependency indices, and the section file range. A missing
`.dep-v0` section is null metadata, not proof the binary has no dependencies.
A rustc commit hash is an inference from a read-only `/rustc/<40-hex>/` path.
For selected crates, use `view: {collection: "packages", names: [...],
fields: ["name", "version", "source"]}` on the first call; explicitly include
`dependencies` for direct index references. Use `detail: "summary"` for counts
and ranges, or retained `inspect_analysis_view` Rust package/edge pages for
subsequent filters. Keep the complete parent Evidence accessible; CLI compact
delivery requires `--evidence_output`.
See the
[Rust binary guide](https://github.com/morluto/rea/blob/main/docs/rust-binaries.md).
- PE Authenticode certificate table: `inspect_pe_certificates` with the explicit
local PE32/PE32+ file. This built-in target-free inspection requires no
Windows SDK. Preserve WIN_CERTIFICATE ranges, types, payload SHA-256, and
PKCS#7 signer/X.509 identity fields, including `subject_key_identifier_hex`
when the signer uses that form. A missing security directory is observed
absence of that table, not proof the image is unsigned in every catalog sense.
Signatures are not verified. See the
[PE certificate guide](https://github.com/morluto/rea/blob/main/docs/pe-certificates.md).
- PE import and delay-load tables: `inspect_pe_imports` with the explicit
local PE32/PE32+ file. This built-in target-free inspection requires no
Windows SDK. Preserve DLL names, named and ordinal functions, ILT/IAT RVAs,
delay-load descriptors, and the pefile import hash of the standard import
table. A missing import directory is observed absence of that table, not
proof the image never imported APIs through another table. Bound snapshots
and loaded IAT contents remain unobserved. See the
[PE import guide](https://github.com/morluto/rea/blob/main/docs/pe-imports.md).
- Windows Shell Link shortcuts: `inspect_windows_shortcut` with the explicit
local `.lnk` file. This built-in target-free inspection requires no Windows
SDK or COM. Preserve header flags and FILETIMEs, IDList items, LinkInfo
volume and network names, StringData arguments and icon location, ExtraData
signatures, and Tracker droid facts labelled derived. Unknown IDList items
and ExtraData signatures stay raw with SHA-256. The target path is not
resolved or executed. See the
[Windows shortcut guide](https://github.com/morluto/rea/blob/main/docs/guides/native/windows-shortcut.md).
- PE TLS callbacks and load configuration: `inspect_pe_load_config` with the
explicit local PE32/PE32+ file. This built-in target-free inspection requires
no Windows SDK. Preserve callback VAs as strings with their RVA, file offset
and section, and the size-covered security cookie, SafeSEH, CFG,
EH-continuation and XFG fields. Guard-flag mitigation claims are inferred
linker declarations, not proof of enforcement. A missing directory is observed
absence of that declaration. See the
[PE load configuration guide](https://github.com/morluto/rea/blob/main/docs/pe-load-config.md).
- ELF, PE, and Mach-O hardening: `inspect_binary_hardening` with the explicit
local file. This built-in target-free inspection requires no pwntools or other
host tool. One image per Mach-O slice. Each property is `enabled`, `disabled`,
`partial`, `unknown`, or `not_applicable`, with the header field or import
behind it. A missing stack-protector or fortify import is `unknown`, not
proof the protection was omitted. Authenticode and Mach-O code signatures
report presence, not validity. Hardened runtime stays unknown and names
`inspect_signature`. The image is not executed. See the
[binary hardening guide](https://github.com/morluto/rea/blob/main/docs/binary-hardening.md).
- ELF note tables: `inspect_elf_notes` with the explicit local ELF32/ELF64
file. This built-in target-free inspection requires no disassembler. Preserve
PT_NOTE and SHT_NOTE ranges, owners, types, descriptor SHA-256, and GNU
build-id or ABI-tag fields. A missing table is observed absence of those
declarations, not proof the image never carried notes. See the
[ELF notes guide](https://github.com/morluto/rea/blob/main/docs/elf-notes.md).
- Code that runs before `main` or at exit in ELF or Mach-O:
`inspect_native_initializers` with the explicit local file (and
`architecture` for a universal Mach-O). This built-in inspection requires no
disassembler. Preserve slot and target addresses, the `resolution` of each
entry, and its limitations. An `unresolved` slot is not a zero pointer, and a
missing table is not proof that no constructor exists. See the
[initializer guide](https://github.com/morluto/rea/blob/main/docs/guides/native/native-initializers.md).
- ELF unwind function ranges: `inspect_elf_unwind_ranges` with the explicit
local ELF32/ELF64 file. This built-in target-free inspection requires no
disassembler. Preserve FDE start, end, size, containing section, exact
symbol name, CIE index, LSDA, and personality. Page with `offset` and
`limit`. A missing `.eh_frame` is observed absence of that declaration,
not proof the image has no functions. `.debug_frame` is reported present
or absent and is not decoded. CFA instructions are counted and not
evaluated. See the
[ELF unwind ranges guide](https://github.com/morluto/rea/blob/main/docs/guides/native/elf-unwind-ranges.md).
- Apple `.ips` crash reports: `inspect_apple_ips` with the explicit local
file. This built-in target-free inspection requires no Xcode, atos, or
pwntools. Preserve first-line metadata, `bug_type` as a string, process and
exception facts, lossless integer registers, usedImages UUIDs, and
documented classification matches. Only `bug_type` `"309"` yields a crash
body; other types, including `"288"` stackshots, return no crash result.
Opened Mach-O and dSYM UUID joins remain unobserved. See the
[Apple IPS guide](https://github.com/morluto/rea/blob/main/docs/apple-ips.md).
- Linux kernel module metadata: `inspect_kernel_module` with the explicit local
ELF32/ELF64 relocatable file. This built-in target-free inspection requires
no Linux host or `modinfo`. Preserve `.modinfo` key order and duplicate keys,
`__versions` CRC hex, section file ranges, and the appended signature
trailer's PKCS#7 SHA-256. A missing section is observed absence when section
names are known, not proof the module never carried that metadata.
`struct module` is not decoded. The signature is not validated. gzip, xz, and
zstd containers are unsupported; decompress into a separate file with an
external codec utility, then inspect the decompressed `ET_REL` image. See the
[kernel module guide](https://github.com/morluto/rea/blob/main/docs/kernel-modules.md).
- Linux kernel version and build config (`CONFIG_*` options such as
`CONFIG_SECURITY_SELINUX`): `inspect_kernel_build_config` with the explicit
local `vmlinux`, raw `Image`, x86 `bzImage`, or `configs.ko`. This built-in
target-free inspection requires no Linux host or `extract-ikconfig`. Preserve
the banner version, config SHA-256, option count, and value kinds; use `name`
for one option. An `IKCFG_ST` string is a config only with a valid gzip member
and `IKCFG_ED`. An absent config may mean the kernel was built without
`CONFIG_IKCONFIG`. Non-gzip payloads are unsupported; decompress externally,
then inspect the result. See the
[kernel build config guide](https://github.com/morluto/rea/blob/main/docs/guides/native/kernel-build-config.md).
- CPython 3.7–3.13 `.pyc` header, nested marshal code objects and bytecode
identity: `inspect_python_pyc` with the explicit local file. This built-in
target-free inspection requires no Python installation. Preserve magic,
PEP 552 hash or mtime/size, nested names, and bytecode SHA-256. Bytecode is
not decompiled, and a decoded header does not prove the matching `.py` is
present. See the
[CPython pyc guide](https://github.com/morluto/rea/blob/main/docs/python-pyc.md).
- Erlang BEAM IFF container: `inspect_erlang_beam` with the explicit local
`.beam` file. This built-in target-free inspection requires no Erlang
installation. Preserve chunk names, atom order, export/import/lambda tables,
and the Code header. For selected facts, prefer `detail: "summary"` plus a
`beam` view (collection, names/indices, fields and paging), retaining the full
Evidence. CLI summary requires `--evidence_output`. Missing table counts stay
null; decoded empty tables count zero. Bytecode is not executed. See the
[Erlang BEAM guide](https://github.com/morluto/rea/blob/main/docs/erlang-beam.md).
- Explicit local SQLite database snapshot: `inspect_sqlite_database`. Inspect
with Node.js 24.x >=24.15 or 26+ providing native SQLite limits. Read schema
first (compact inventory by default), or select an exact ordinary table
and `columns` when rows are needed. Defaults are selected-table schema and
20 rows; request `schema_scope: "database"` for the complete stored schema. Preserve both database and existing sibling WAL identity; row samples
report truncation and have unspecified order. Use a quiescent snapshot rather
than assuming a live database/WAL pair was copied atomically. See the
[SQLite guide](https://github.com/morluto/rea/blob/main/docs/guides/data/sqlite-databases.md).
- Explicit Linux ELF file for offline layout, symbols, relocations or static
mitigation evidence: `inspect_binary_layout`. This target-free operation uses
caller-supplied pwntools without opening a disassembler database. Preserve its
raw locations and inference/coverage limits; see the
[offline binary guide](https://github.com/morluto/rea/blob/main/docs/guides/native/binary-diagnostics.md).
- Supplied Linux x86-64 ELF core: `inspect_recorded_crash`. Read every recorded
thread, raw note and source-bound register without opening a live target.
Historical PIDs do not select live processes. Request `include_debugger_context`
only when core-only mapping candidates help; see the
[recorded crash guide](https://github.com/morluto/rea/blob/main/docs/guides/native/recorded-crashes.md).
- PE resource type/name/language trees and static icon group references:
`inspect_pe_resources` with an explicit path. This portable operation requires
no active target or engine; opaque payloads retain file ranges and SHA-256.
- PE export directories and ELF dynamic-symbol exports: `inspect_native_exports`
with an explicit path; portable, no active target or engine.
- Native executable, library, or analysis database: `open_binary`, then
use focused analysis tools directly; call `binary_overview` when metadata or
inventory context is useful and available from the selected provider.
For an existing IDA MCP configuration, use `open_binary` with the original
input binary and `provider_id: "ida"`; do not pass an `.idb` or `.i64` database.
The legacy attached profile binds the already-open GUI input by SHA-256 and
leaves its database open. The modern headless profile creates and closes an
owned private database without saving. Installation and registration reuse
[mrexodia/ida-pro-mcp](https://github.com/mrexodia/ida-pro-mcp); see the
[IDA provider guide](https://github.com/morluto/rea/blob/main/docs/providers/ida-provider.md).
For migration to the separate native IDA MCP plugin, follow the guide's
[legacy attached migration](https://github.com/morluto/rea/blob/main/docs/providers/ida-provider.md#migrate-a-legacy-attached-registration).
Replace the upstream registration explicitly; modern mrexodia tools are not the
native profile. Native attachment preserves the database but does not provide
direct callers; check session availability and the guide's verification limits.
Proceed directly to `analyze_function`, pseudocode, or function/string searches
after opening; IDA does not supply `binary_overview`. Consult session availability
when composing broader workflows. Modern direct callers and unsupported dossier
facets remain unknown; live IDA results are not replayed from snapshots.
With `REA_KUNA_COMMAND` configured, `provider_id: "kuna"` answers procedure
lists, lookups, pseudocode, assembly and `analyze_function` from Kuna's cached
whole-program export. Callers/callees use a separate body-free graph shared by
the session; `xrefs` returns exact-address incoming sites and filters forwarding
aliases. These queries and inventory lookups do not trigger a C export. Indirect
targets remain unknown and can make relationship coverage partial. Reference
caches expire on close; a fresh CLI process repeats the native reference walk.
Typed references, CFG, raw-memory tools and annotations remain unsupported. A cold first
answer can come from a single-function run (its limitations say so); assembly
waits for the export. For pseudocode and assembly, `raw_result.source` names the
cached `.c`/`.asm` file and byte span, which you may search directly instead of
calling per function. See the
[Kuna provider guide](https://github.com/morluto/rea/blob/main/docs/kuna-provider.md).
If the user says only "my app" without a name, path, or target already selected
for this task, ask which app before searching for artifacts. Paths in unrelated
saved reports and nearby test fixtures do not select the user's target. Resolve
an actual human-readable app name to one clear installed artifact when possible;
ask when matches are ambiguous. Never choose an example app on the user's behalf.
In a target-free session, use `open_binary` to bind any archive/package or
native target whose analysis tool operates on the active target. Do not call an
unadvertised tool; inspect `binary_session` with `{}` and its
`result.tool_availability` for availability reasons and remediation. Expand each
group's `names` into entries, inheriting the inventory-level client requirements
unless the group overrides them. The grouped name union is the complete tool
inventory; use `tools/list` for canonical tool schemas. The default summary keeps
each provider candidate's availability and target-support status, code and
reason; `omitted_fields` lists what it leaves out. Request `{"detail": "complete"}`
only when you need an unavailable candidate's remediation, provider
diagnostics, candidate capability sets, or tool surface, effect and annotation
flags; in those closed flag vocabularies, listed
flags are true and omitted known flags are false. Provider candidate facts
describe possible providers, while top-level session capabilities describe the
current route and `analysis_provider_binding` names the selected provider. Do
not treat those scopes as interchangeable.
For editable Ghidra analysis that must survive a later agent process, select
the [owned Program checkpoint workflow](references/native-and-artifacts.md#continue-editable-ghidra-analysis-across-processes)
before making metadata edits. It is separate from Evidence/Unknown persistence.
For a Ghidra startup timeout on a large native target, follow
[deadline recovery](references/native-and-artifacts.md#ghidra-startup-deadline-recovery)
before abandoning deep analysis or repeating the same cold import. The default
330 seconds can be changed for new startup attempts through
`update_analysis_settings` using `ghidra.startup_timeout_ms`. Changing the
server's launch environment instead requires a new REA process.
## Work summary-first
Start with the default result and use its inline Evidence and graph context.
For a JavaScript application or ELF layout whose size is unknown or large,
call `analyze_javascript_application` or `inspect_binary_layout` with
`"detail": "summary"`, then inspect the retained `parent_evidence_id` with
`inspect_analysis_view`. Over MCP, `analyze_function` is summary-first without
a flag: its default reply is the summary, and the complete dossier stays
retained under `parent_evidence_id` for facet paging. Pass that ID as a
`retained-evidence` reference to `compare_functions`, or inside
`match_native_functions` `left_observations`/`right_observations` or
`build_call_path.functions`; request `"detail": "complete"` only when the
dossier must travel inline.
Use the same exact-reference form in other advertised logical Evidence inputs,
including optional sources, arrays and nested sources across domains. Each
source can independently be inline or retained; preserve its source identity,
coverage, partial facts and unknowns. A summary or inspection page has its own
Evidence ID: use `normalized_result.parent_evidence_id` when the consumer needs
the complete source. For portable CLI follow-ups, commands advertising
`--evidence-bundle` resolve references from that selected exported bundle.
See [Evidence composition](references/evidence-workflows.md) for scope and
recovery; file selectors are supported only where explicitly advertised.
Page module identities and select the returned `node_id` before requesting an
item; a single file path can represent several graph roles. Do not repeat an
identical analysis or rejected call without a changed input or resolved blocker.
Recheck session state when a lifecycle change makes that status useful.
Make a focused follow-up only when the
returned result leaves a specific question unanswered.
If MCP reports `resource_constraint` with `details.resource: "transport"`, call
`inspect_analysis_view` with its reported same-session Evidence reference for a
summary, one section/module, or a stable page. Use `trace_application_feature`
once a seed is known, or call `export_evidence_bundle` to write the complete
session to a selected path. The analysis remains complete in the ledger; avoid
repeating it merely to request the same oversized response. Complete CLI JSON
output also streams without a single MCP frame.
When the retained parent ID is unknown, use `get_evidence_bundle` with
`detail: "summary"` and exact filters for operation, target SHA-256,
analysis-profile digest or canonical procedure address. Select its returned
Evidence ID for the next view instead of requesting the complete bundle. When
that record has no supported view (for example an annotation), read it with
`detail: "records"` and an `evidence_id` filter; `missing_evidence_links`
names linked sources this session does not retain.
`retention: "complete-record"` means the whole record is stored; native
value-flow truncation and unknown metadata remain separate. For an exported
portable bundle, use `rea inspect-evidence-bundle` with JSON containing `path`,
`detail: "summary"` and the same `filters`. Reading that file still requires
validating its full contents within the local memory budget.
A client can also truncate a successful tool result without a REA transport
error. Treat that preview as incomplete; use the retained parent ID for smaller
views or export when the full record is needed. Do not parse the preview as
canonical JSON or repeat the producer. For JavaScript, read the
[module-view and trace recipes](references/javascript-applications.md) before
drilling into a large graph; a broad bidirectional trace can expand unrelated
containment relationships even after summary analysis.
Every conclusion must distinguish observations, inferences, and unknowns. Cite
Evidence IDs, preserve limitations and incomplete coverage, and never imply
that static analysis observed execution. Runtime requests execute the declared
target and lifecycle; do not broaden the target or action beyond those fields.
Within the user's requested investigation, call available analysis tools
directly. REA does not require permission grants or per-call approval flags.
The agent client can independently require approval. Follow the declared request
scope and the host's actual access requirements; use the recovery guide for an
observed client-policy rejection.
## Plan broader investigations
For requests that span multiple features or subsystems, use a staged workflow:
1. Turn the request into a checklist of questions and the evidence each answer
needs. Resolve target identity and constraints from the conversation and
workspace before asking for information again.
2. Inspect the current REA session, artifact identity, saved analysis database,
bookmarks, and prior evidence. Reuse matching state; do not open duplicate
sessions or repeat identical analysis.
3. Start with the smallest useful overview or inventory. Follow each question
from its entry point through relevant data and state changes to its result.
Batch related operations around a specific hypothesis, then expand only when
the returned evidence leaves a concrete gap.
4. Inspect relevant packaged resources and configuration alongside code when
they affect the question. Use format-aware inventory and parsers; do not
infer behavior from filenames, strings, or layout alone.
5. Corroborate a conclusion with the evidence type it requires. Use runtime
observation only when static evidence cannot answer the question and the
required host runtime and OS access are available. For JavaScript behavior,
run probes against the actual app through the available browser, Electron,
or process capture workflow.
6. Decompose work into independent questions. When parallel workers are
available and the questions do not depend on one another, assign distinct
scopes, point workers to existing evidence, and ask them to return sources,
conclusions, and unresolved gaps. Otherwise, work sequentially.
7. Keep a concise finding ledger linking each conclusion to Evidence IDs,
confidence/evidence type, search boundary, and remaining unknowns. Update
the shared index or investigation report so later passes can reuse results.
Before finishing, revisit the original checklist. Mark each question as
answered, partially answered, or unresolved based on its evidence; keep
bounded negative searches bounded, and do not describe a broad investigation
as complete while required questions remain open.
## Read only the relevant guide
- Native binaries, managed assemblies, archives, and extraction:
[references/native-and-artifacts.md](references/native-and-artifacts.md)
- ASARs, extracted JavaScript, feature tracing, and version comparison:
[references/javascript-applications.md](references/javascript-applications.md)
- Android APK declarations, classes, methods, and static references:
[references/android-applications.md](references/android-applications.md)
- Native instrumentation, debugger and process capture; browser/Electron
observation; static/runtime comparison:
[references/runtime-observation.md](references/runtime-observation.md)
- Evidence paging, comparisons, residual unknowns, and verification:
[references/evidence-workflows.md](references/evidence-workflows.md)
- Agent environments (AgentEnv bundles) of a web application, recorded from
a live instance or a HAR:
[references/environments.md](references/environments.md)
- Tasks for a desktop application's exported environment:
[references/environments.md](references/environments.md#tasks-for-a-desktop-application-environment)
- A desktop app environment from a request, built and witnessed on build
computers: [references/desktop-build.md](references/desktop-build.md)
## Finish the task
Explain findings in plain language and tie them to returned evidence. When the
user asks to build something, use normal coding tools and separate observed
behavior from design choices. Close an opened native session with
`close_binary` when its engine is no longer needed. Evidence references and Unknown
history remain in memory on the same MCP connection. End the investigation with
`reset_investigation`; supply `save_path` to checkpoint the complete bundle before
clearing. Import the saved bundle to restore original references and Unknown
history in a new process. Retention is bounded and is not automatic persistence.
@@ -0,0 +1,63 @@
# Android application artifacts
Use the connected server's advertised Android tools. Repository main and npm
4.1.0 include this family. A skill installation alone cannot add it to an
older server.
Start with `inspect_android_package` on the caller's APK path for package
identity, manifest declarations, coverage, and inline Evidence. Use
`search_android_classes` to find candidates, `inspect_android_class` for one
class's members, `inspect_android_method` for decompilation, and
`trace_android_references` for incoming static references. Each call takes its
own APK path and explicit selectors; do not open a native session first.
Choose an `overload_index` from the class inventory when methods are ambiguous
instead of guessing.
For JNI implementations, discover `inspect_android_jni_bindings`. Select raw
DEX class/method identities and optional descriptors, ABI or logical library
paths. Prefer `detail: "summary"`; complete Evidence remains retained and
`android-jni` views page declarations, libraries, candidates or binding rows
with exact fields. Global native-pair ambiguity survives class selection.
Preserve slot/relocation provenance and source coverage: matched means observed
candidate presence; partial scans do not establish uniqueness or absence.
This reads DEX/ELF bytes without loading the APK into the decompiler or executing
target code. Runtime registration and loading remain unknown. See the
[JNI guide](https://github.com/morluto/rea/blob/main/docs/guides/android/android-analysis.md#link-native-declarations-to-jni-candidates).
For an R8/ProGuard release build with its `mapping.txt`, pass the absolute
`mapping_path` to `inspect_android_class` or `inspect_android_method`. Prefer
`detail: "summary"` to receive normalized symbols or the selected body without
duplicate raw class metadata. Complete Evidence stays retained under the linked
parent ID and can be exported; CLI summary delivery requires `--evidence_output`.
Omitted or complete detail preserves the full inline response. Keep
selecting by observed names; the separate `mapping` block reports each symbol
as `mapped`, `ambiguous` or `unmapped` after checking DEX types and prototypes.
REA does not verify that the mapping belongs to the APK build.
The provider requires a separately supplied headless JADX MCP JAR 0.7.1 and
Java 17 or newer. `REA_JADX_MCP_JAR` selects the absolute JAR path; `JAVA_HOME`
can select Java. Use `rea doctor --provider jadx --json` to check the JADX
configuration and obtain remediation for missing prerequisites. Java and the
headless JADX MCP JAR remain caller-supplied; REA setup does not install or
configure them. Configuration readiness does not establish successful analysis
of a particular APK.
Linux and macOS use the POSIX owned-process boundary. Windows x64 requires the
matching bundled native controls and uses an owned Job Object with bidirectional
stdio. A full JDK, including `jdk.compiler`, is required on every host; Windows
`JAVA_HOME` selects `bin\java.exe`. Consult the Android guide for actual host
verification coverage. Calls use one worker and provider deadlines; optional
`REA_JADX_HEAP_MIB` selects the JVM heap and `REA_JADX_OPERATION_TIMEOUT_MS`
the per-operation deadline (integer milliseconds from 1 to 2147483647, default
120000). To change that deadline on a running MCP server, set
`jadx.operation_timeout_ms` through `update_analysis_settings`, then retry after
work and cleanup settle. Newly accepted operations use the override; accepted
work keeps its budget. Changing the launch environment requires a new process.
The MCP client's tool timeout must also allow the selected deadline. The APK is never launched,
and no emulator is required.
Manifest entries and decompiled text are static observations, not execution or
policy enforcement. Incoming references are recovered static relationships;
reflection, dynamic loading, and unresolved calls remain unknown. Preserve
artifact digests, locations, diagnostics, and incomplete coverage. For archive
members rather than code analysis, use `open_binary` and `inspect_artifact`.
@@ -0,0 +1,252 @@
# Connection and task recovery
Read this guide only for an observed connection, command, approval or provider
blocker. Keep the user's target and question in view. Explain what prevented the
operation and the next useful step; after repair, continue the investigation.
Commands below use an installed `rea`. If none is available, use the package
invocation in `SKILL.md` when acquiring REA is authorized. Follow the connected
server's schemas and the installed CLI's help rather than assuming repository
main and an older release expose the same arguments. Tool-name prefixes belong
to the client: find the canonical REA operation in its actual tool listing and
server association before concluding that it is absent. A shared prefix alone
does not establish which server an approval wildcard covers. See
[client tool names and discovery](https://github.com/morluto/rea/blob/main/docs/getting-started/client-configuration.md#client-tool-names-and-discovery).
## Choose the remedy from the observed failure
| Observation | Meaning and next step |
| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| No REA tools in the active session | If REA was installed as a client plugin, follow the plugin section below. Otherwise inspect registration with `rea doctor --client codex --json`. A skill installation alone does not register MCP. |
| Missing, malformed or stale registration | Prepare a scoped setup plan; apply only authorized changes with backups. |
| Aligned registration, but no connected tools | Restart/reconnect the client. Doctor checks registration; it cannot establish the current agent's connection. Inspect the client's launch error if reconnection fails. |
| MCP connection dropped or REA could not start | If the connection drops, retry that call once only when it is safe to repeat or known not to have started. If it may already have changed the target, or the same failure returns, tell the user REA's connection failed and ask them to restart the MCP client. If REA could not start its connection, retry once, then tell the user and ask them to restart the MCP client. Do not retry a startup failure that returns the same unchanged result. Run `rea doctor` when REA could not start or tools stay missing after restart. Doctor cannot restore a live connection by itself. |
| `MCP tool call requires approval, but approval policy is never` | Codex rejected the call before REA executed it. Do not install an engine or repeat setup to solve this client-policy decision. See the approval section below. |
| REA returns `provider_unavailable` or an unavailable tool entry | Read that operation's provider diagnostics or `binary_session.result.tool_availability`. Expand each group's tool names and inherit the shared client requirements unless overridden. Use the operation's availability, reason, and remediation; a complete tool catalog does not imply every operation is available. Request `{"detail": "complete"}` only for provider diagnostics. |
| `rea` is missing in a child shell, or Node is incompatible | Inspect the actual shell PATH and launcher/runtime. A prefix installation may be absent from a login shell's PATH; this does not establish a broken MCP registration. |
If the client cannot launch its shell/sandbox helper or read the installed skill,
inspect its launch diagnostic. For example, Codex can refuse helper aliases when
CODEX_HOME is under `/tmp`; use an authorized profile outside the OS temporary
directory. REA setup cannot repair the client's executor. Preserve the sandbox
instead of silently disabling it to make an analysis command work.
Static JavaScript and ordinary ZIP inventory need neither Hopper nor Ghidra.
HAR inspection is offline and does not require a live browser. Native
decompilation, Android analysis and firmware extraction have their own
bring-your-own prerequisites. Use supported inventory/static evidence when it
answers part of the question, and name the unavailable evidence separately.
Do not substitute another target. Label observations from ordinary shell tools
as such; only actual REA output supplies REA Evidence.
## Distinguish client and server deadlines
A host message such as `timed out awaiting tools/call` with no REA result means
the client's deadline expired before a result arrived. It does not establish
that REA finished, canceled successfully, or released all resources. Do not
repeat the unchanged analysis. Inspect available session/provider state and
retained Evidence only when useful; an idle `binary_session` is not proof that
every target-free operation stopped. Never invent a missing Evidence reference.
Allow the host tool deadline to cover execution, cancellation/cleanup, and
response delivery. In Codex, the setting is
`mcp_servers.<server-id>.tool_timeout_sec` in **seconds**, not
`startup_timeout_sec`. Select a value for the actual operation budget and
headroom, not a universal timeout. See the
[official MCP configuration reference](https://learn.chatgpt.com/docs/extend/mcp?surface=cli).
Apply configuration changes only within the authorized scope, preserving
unrelated entries and backups; reconnect/restart as required by the host.
When configuration is not editable, report the setting needed or use an
authorized CLI fallback. Do not silently broaden permissions or switch targets.
A returned REA `provider_timeout` instead means the **server/provider** reached
its deadline. Follow its `remediation.action`, reuse accessible partial Evidence,
and resolve reported incomplete cleanup before retrying. Change a request
field or a `REA_*` setting only when the returned remediation explicitly
recommends that change. Restart REA when that text says the value is read at
startup. A mention that a control does not raise the deadline is not a
recommendation to change it. Do not invent a setting the remediation does not
name. Raising only the client deadline does not change
this server budget. For static JavaScript,
`analysis_timeout_ms` (CLI `rea analyze-javascript-application <path>
--analysis-timeout-ms <milliseconds>`) controls each source's
startup, analysis and transfer deadline in **milliseconds**. Increase that
budget or reduce semantic work / select exact inventoried `source_paths` when
the task permits. Omit `source_paths` for all sources; `[]` is invalid.
`detail: "summary"` reduces delivery, not analysis work. A per-source budget
does not bound the elapsed time for an entire multi-source application.
Partial-Evidence inspection can avoid recomputation; an unavailable reader is
not itself a reason to block a corrected static-analysis retry.
The shorthand `rea analyze <path>` does not expose this timeout flag; use the
explicit JavaScript command when changing that budget.
For invalid arguments, use the connected tool's current input schema, or the
installed CLI's command help. Refresh the catalog if stale; do not guess fields
from an older skill or return the entire catalog to repair one field. Correct
all reported fields before retrying.
## Recovery ladder
Follow the returned remediation. Change a request field or a `REA_*` setting
only when that text explicitly recommends the change. Restart REA when it says
the value is read at startup. A mention that a control does not raise a limit
is not a recommendation to change it. Do not invent a setting. Host memory and
`NODE_OPTIONS` do not raise a REA admission unless that remediation says so.
- MCP connection dropped: retry that call once only when it is safe to repeat
or known not to have started. If it may already have changed the target, or
it fails again, tell the user REA's connection failed and ask them to restart
the MCP client. Do not run `rea doctor` for the drop itself.
- REA could not start its connection: retry once, then tell the user and ask
them to restart the MCP client. Run `rea doctor` when REA could not start or
tools stay missing after that restart. Do not retry a startup failure that
returns the same unchanged result.
- `cleanup_incomplete`, or retained cleanup owners in the error details: retry
that exact cleanup owner. Do not start new work that needs those resources
until that owner confirms release. Repeating the original call is not that
retry.
- Capacity, when the result is `resource_constraint` and `retryable` is true:
wait for the active admission to finish, then retry within the reported
limits. Do not restart REA to free the slot.
- `configuration_invalid`: correct the named setting in the environment that
starts REA (the shell or the MCP registration), then restart REA. Run
`rea doctor` to see the `runtime-configuration:` check. Re-run `rea setup`
for that client when the registration must carry the new setting, then
reconnect.
- `invalid_request`: correct the reported fields from the current schema. Do
not repeat the unchanged call.
## REA installed as a client plugin
A Claude Code, Codex, or Cursor Marketplace plugin owns its `rea` server and
this skill. Doctor's `--client` and `--skill` checks and `rea setup` read and
write only setup-owned state, so for a plugin-only install they report
`missing`, and applying setup would add a second `rea` server. If this skill
was loaded from a plugin, or the client lists a `rea` plugin, do not prepare a
setup plan for missing tools. Instead:
- Check that the plugin is installed and enabled with the client's own
listing: `claude plugin list` and `/mcp` in Claude Code, `codex plugin list`
in Codex, and Cursor's plugin settings for a Marketplace install.
- Restart or reconnect the client, then read its launch error for the `rea`
server if tools are still absent. Claude Code and Codex start the plugin's
bundled runtime; a missing build or version mismatch requires a reinstall,
or a fresh build when developing from source. A Cursor Marketplace install
starts `npx` at the version pinned in the plugin's `mcp.json`. A failed
download needs network access to that published version.
- Check analysis engines through the connected server's
`binary_session.result.tool_availability`. `rea doctor --provider <id> --json`
from a shell reports that shell's environment, which can differ from the
environment the client gave the plugin server. If you still run it through
the package runner, use the version pinned in the plugin manifest's server
entry (`rea-agents@<version>`), not `@latest`, so it matches the server the
plugin starts.
Use the setup repair below only when the user wants a setup-owned registration
instead of the plugin, and remove or disable the plugin first.
## Repair registration when needed
For Codex:
```bash
rea doctor --client codex --json
rea setup --client codex --dry-run --json
```
Use the current supported client's ID: `claude_code`, `claude_desktop`, `codex`,
`cursor`, `gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`,
`copilot_cli`, `commandcode`, `qwen_code`, `vscode`, `trae`, `grok_build`, `omp`, `pi`,
`hermes`, `qoder`, `droid`, `zcode`, `dsh`, `freebuff`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a
scope. `grok_bot` uses a chat registration step rather than local `mcp.json`.
For unsupported clients, use manual stdio registration with the selected
installation’s absolute runtime and entry script, or continue through the CLI.
Acquire packages from a trusted directory outside the target project. Keep a
package-runner installation and its runtime available while registrations use it.
Show the plan's exact paths, backups, changes and bundled-skill replacement.
Obtain approval for writes when that concrete scope is not already authorized,
then apply the same scope with `rea setup --client codex --yes`. Add
`--install-hopper` only when separately needed and explicitly approved. Do not
install or upgrade unrelated prerequisites. Restart/reconnect, verify tools
actually appear, and resume the original task. If they remain absent, inspect
the launch failure rather than looping through setup.
## Codex tool approval is separate from REA readiness
REA tools have no permission-grant handshake. Codex can still require approval
because an operation creates owned resources, launches a helper or has cleanup
effects. An advertised tool or successful doctor result does not override that
client policy. `workspace-write` alone does not resolve an MCP approval
rejection, and `approval_policy = "never"` does not mean every MCP tool is
preapproved.
When interactive approval is available, use the client's approval flow for the
requested operation. In an unattended run, continue through an authorized CLI
workflow if the execution environment permits it, or report the blocked
operation and the approval needed to proceed. Do not retry the same rejected
call, silently broaden approvals, or rewrite MCP effects as read-only.
For an operator who explicitly wants to preapprove named operations in a
disposable Codex profile, current Codex supports per-tool configuration. For
example, opening and closing an analysis session can be scoped independently:
```toml
[mcp_servers.rea.tools.open_binary]
approval_mode = "approve"
[mcp_servers.rea.tools.close_binary]
approval_mode = "approve"
```
Use the actual server ID and only the operations whose effects were approved;
this example does not approve inspection, extraction or runtime execution.
Show the proposed config edit and preserve unrelated entries and backups before
writing. Managed policy can constrain client settings. Consult the
[Codex configuration reference](https://learn.chatgpt.com/docs/config-file/config-reference)
for the installed client's supported settings. This is an optional recovery
choice, not a prerequisite for every REA task.
## Repair an editable REA limit
If `details.limit` names an editable setting and its bounds, call
`update_analysis_settings` directly with a `changes` entry containing `setting`,
`action: "set"` and `value`. `inspect_analysis_settings` is optional when those
facts are already present. Retry the same input after work and cleanup settle.
Runtime defaults affect new work only; explicit per-call arguments take
precedence and must be corrected directly when selected. Reset restores launch
values. Updates survive target close/open but disappear on REA restart. They do
not edit host deadlines, response buffers, registrations or fixed limits.
## Continue through an installed CLI
Check the command environment only if the launcher fails:
```bash
command -v rea
command -v node
node --version
rea --version
```
Use the public launcher at its known installation path with a supported existing
Node on PATH when the child shell loses a prefix installation. Avoid internal
`dist/cli.js` entrypoints or global toolchain changes. MCP may have its own valid
launcher even when a shell cannot resolve `rea`.
For a user-supplied JavaScript tree or ASAR, select output files in the authorized
workspace and save complete Evidence before reading a view:
```bash
rea analyze-javascript-application /absolute/path/to/app --json > app-evidence.json
jq -c '{source: {kind: "inline", evidence: .}, view: {kind: "summary"}}' app-evidence.json > app-view.json
rea inspect-analysis-view app-view.json
```
These commands do not register MCP or install an analysis engine. CLI JavaScript
JSON is complete and can be very large; redirect it rather than loading it into
context. Use an existing JSON processor if `jq` is absent, without installing
one just for this recipe. Inspect a module page or item using the same inline
source and the [JavaScript recipes](javascript-applications.md). Preserve graph
coverage, limitations and unknowns. Native CLI analysis still requires its
selected engine, and all CLI operations retain the host's execution permissions.
@@ -0,0 +1,860 @@
# Desktop app environments from a request
Use this when someone asks for an environment of a Linux desktop app with a
task, such as "Build me an Inkscape environment where the agent changes a
poster's headline and saves it". You are the building agent: you drive the
app on a build computer through REA's MCP tools, write the task, export the
bundle and witness it in a fresh computer. REA runs no model. The tools are
MCP-only and need a Linux host with Docker, agentenv and REA's packed
runtime. When a start reports one missing, tell the person to run
`rea doctor --provider desktop_build` and apply its fixes, or
`rea setup --client <client> --desktop-build` and restart the client, and
stop.
For the whole build:
- Drive the app only with `act_on_build_computer`. Never script it, run
commands in the computer or write its files another way. The one command
you write for the computer is a task's reader, which REA runs to check a
file; it never makes the seed or the edit.
- Ask the upfront questions once, then work on your own. Pause only when
blocked.
- The seed's content is synthetic. Never put real personal data in it.
- Close every computer you start, on success and on failure.
- Note the time you start; the report gives the build time.
## Ask once, upfront
In one message, ask only what the request leaves open, and keep the list for
the report. Skip each question the request answers; when it answers them all,
start without asking:
1. The app and the task, if either is unclear: what changes, in which
document.
2. Whether they have a starting file of their own; otherwise you make one.
3. Whether they want the tasks measured after the witness: with which
model, within which budget, and whether to harden a task until it lands
in the band.
4. If so, whether they want to review the tasks before those solves.
When you cannot ask, as in a headless `claude -p` run, do not stop: take
these defaults for whatever the request leaves open, say which you took in
your first message and in the report, and go on. Install from the Ubuntu
archive, make a synthetic seed, skip the review, and measure each task
within $4 in all, with the model the request names, without hardening.
## When you are blocked
Stop, say what you need and wait only for:
- a licence the app or its installer asks someone to accept: never accept it
on your own; name it and where to read it, and go on only once the person
says they accept it. Then record their words with `record_build_consent`,
kind `licence`, and accept it on their behalf;
- credentials: ask the person to write each secret, and nothing else, in a
file named after a handle you choose, such as `keepassxc-master`, in
`~/.config/rea/build-secrets/` on this host (under `$XDG_CONFIG_HOME` when
it is set), readable only by them (`chmod 600`), and wait until they say it
is there; then type it with `type_secret` (see Drive the app). Never ask
for a password or key in chat, and never read, list or print that folder;
- a signed-in state in the seed: by default the export withholds it (see
Sign-in and licence state). Seed it only when the person says the bundle
may hold it: record their words with `record_build_consent`, kind
`seed_sign_in`, and export with `seed_sign_in: true`;
- an install this flow cannot do, below.
Decide everything else yourself and note it in the report.
## Sign-in and licence state
What an app writes when someone signs in or activates a licence is withheld
from the seed: the bundle's receiver supplies their own. Mark
`pre-sign-in` with `mark_build_checkpoint` just before the sign-in or
activation and `post-sign-in` just after, both before `seed`: every file
under the owned paths that changed between them is withheld, and the draft
leaves it out. A file the app writes at first launch, such as a trial's
licence file, changes before you can mark anything: name it in
`export_build_environment`'s `withhold`. The export lists each withheld file
in `withheld_files` with the environment variable a deploy reads it from and
the handle of the secret REA keeps its bytes in; a check may not name one.
The export refuses a seed file holding a withheld file's exact bytes, such as
a `.bak` copy, but a backup that differs from it is not caught: trash backups
before `seed`.
Explicitly withhold any other files the person identifies as sensitive;
REA does not infer secrecy from a value's shape or a file name.
## Choose the install
The computer is Ubuntu 24.04 (noble) on this host's architecture only. Use
the first source that has the app:
| Source | `install` |
| ---------------------------- | ------------------------------------------------------------------------------------------- |
| Ubuntu archive | `{"kind": "archive", "package": "inkscape"}` |
| PPA or vendor apt repository | `{"kind": "apt_repository", "package", "uri", "suite", "components", "key_file"}` |
| `.deb` by URL | `{"kind": "package_file", "url", "sha256", "architecture", "dependencies"}` |
| AppImage by URL | `{"kind": "appimage", "url", "sha256", "architecture", "extra_packages"}` |
| Tarball by URL | `{"kind": "tarball", "url", "sha256", "architecture", "program", "name", "extra_packages"}` |
- A PPA's `uri` is `https://ppa.launchpadcontent.net/OWNER/NAME/ubuntu`,
`suite` `noble`, `components` `["main"]`. `key_file` is the repository's
signing key saved on this host; for a PPA, fetch it from
keyserver.ubuntu.com by the fingerprint its Launchpad page shows.
- When the app exits at launch for want of a runtime piece its package does
not install, start a new build computer with that package in
`extra_packages`, up to 16 archive names; the export installs them too.
MyPaint needs
`{"kind": "archive", "package": "mypaint", "extra_packages": ["librsvg2-common"]}`
for its SVG icons, and Freeplane `["openjdk-17-jre"]`, since its launcher
refuses noble's Java 21. Name one up front, too, when a package the app
only recommends holds its content, such as QElectroTech's element library
`qelectrotech-data`, without which no component can be placed.
- Take the `.deb` path only when the app is not in the archive at all, not
to add a package. Ask the requester for its URL and SHA-256; do not fetch
a vendor's download page yourself. List its `Depends` as `dependencies`,
with any runtime piece it lacks: it installs with the network off.
- Take an AppImage or a tarball only when no package has the app. Pin it by
the release file's URL for this host's architecture and the SHA-256 its
project publishes, such as a GitHub release asset's digest or the
release's `SHA256SUMS`; ask the requester when the project publishes none.
The computer downloads it when the bundle is put, refuses other bytes, and
unpacks it under `/opt/rea-apps/<desktop_entry>` with the network off; the
export downloads the same file.
- An AppImage installs its own desktop entry, so `desktop_entry` is the
`.desktop` file at the top of the image, such as FreeCAD's
`org.freecad.FreeCAD`. A wrong name fails the start with the entries the
image has: start again with one of them. The image brings its libraries,
so `extra_packages` is rarely needed; FreeCAD 1.1.4's needs none.
- A tarball has no entry of its own. `program` is the program's path inside
the archive, such as `firefox/firefox`; REA writes an entry named
`desktop_entry` that runs it with the files it opens, and `name` is what
the Applications menu shows. A tarball uses the computer's libraries: name
what its program lacks in `extra_packages`. gzip, bzip2 and xz archives
are read, zstd ones are not.
- Flatpak and snaps are not supported: say so and stop. Flatpak's sandbox
needs user namespaces the computer's container does not grant, so a
Flathub app installs but never opens; ask for the project's AppImage,
tarball or `.deb` when it ships one. An archive package that only installs
a snap, such as noble's `firefox`, counts as a snap. A package with no
build for this host's architecture (some PPAs are amd64 only) cannot be
installed either.
- `desktop_entry` is the package's `.desktop` file name under
`/usr/share/applications` without `.desktop`, such as
`org.inkscape.Inkscape`. Add `window_class` when you know it; Inkscape's
is `Inkscape`. AbiWord's entry is `abiword` and its class `Abiword`.
- To find the entry, read the package's file list: its page on
packages.ubuntu.com, or `apt-get download PKG && dpkg -c PKG_*.deb` where
apt runs. The entry may ship in a companion `-data` or `-common` package,
as Planner's in `planner-data`: read that package's list too. The entry's `StartupWMClass` may not be the window's real
class, so do not take `window_class` from it: MuseScore 3's says
`mscore3`, its window's is `MuseScore3`. The export uses the class the
capture saw.
## Drive the app
- Pass `computer_id` and the action's fields at the top level: click
`{x, y, count}`, scroll `{x, y, direction, amount}`, a screenshot region
`[x0, y0, x1, y1]`. The rest are in
[the build-computer guide](https://github.com/morluto/rea/blob/main/docs/desktop-build-computer.md).
- Take a `screenshot` before pointing: points are pixels of the latest
frame. If a response is too large, ask for a `region`, or `jpeg` with a
lower `quality`. A screenshot with `region` does not capture the screen
again; it crops the latest frame, so take a full screenshot, or a `wait`
with `screenshot: true`, first. The crop then becomes the latest frame:
take a full screenshot before pointing again.
- A drawn caret does not prove keyboard focus. After using a toolbar
control, click into the document, then type a few characters and look
before a long text.
- Confirm a dialog with `key` Return, not a trailing `\n` in `type`.
- An action's own screenshot is taken at once. After opening a menu, a
dialog or a window, look with
`{"action": "wait", "ms": 500, "screenshot": true}`.
- On a computer started from a request the app is not open at first, and
`start_build_capture` opens it: never open it yourself before that. Do not
close it while capturing; the capture ends when the app exits. When the
app is closed at any other time, open it from Applications → Other → the
environment's name.
- Type a secret with `{"action": "type_secret", "secret": "<handle>"}`,
only into a field that masks what is typed: check in a screenshot that it
is a password field, which shows dots. A screenshot shows any field that
shows its text, such as many licence-key boxes, so never type a secret into
one: say the app needs a secret the build cannot type unseen, and stop.
`type_secret` takes no `screenshot`; look with a `wait` after it. A refusal
names the secret's file and what is wrong with it: tell the person, never
the value.
- Prefer shortcuts and typing to long pointer paths: `key` takes `keys`, an
array holding one chord's keys, such as
`{"action": "key", "keys": ["Control", "s"]}`, and `type` turns `\n` into
Enter.
- Before `type`, check in a screenshot that a text cursor or a focused
field is there. In apps with single-key shortcuts, such as Inkscape, text
typed without one runs as commands.
- Save As differs by app. Check the dialog's current folder first: most
open in the home, so click Documents in the sidebar, but Scribus opens in
`~/Documents` and its sidebar has no Documents entry. Type only the file
name. A dialog taller than the 1280×800 screen hides its buttons:
maximize it from its title bar, or confirm it with `key` Return, as when
HomeBank's Save button is off-screen. When a GTK Save dialog ignores
clicks or runs off the screen, type `~/Documents/<name>` in its Name field
and press Return.
- Answer the app's save questions, such as keeping the format, then check
the saved path in the title or status bar, or the dialog's folder when
the app shows only the name, and that it shows no unsaved mark, before
you mark a checkpoint or check. Where the title never shows one, as in
HomeBank, a greyed Save or Revert, or the file reopened from recent
files, confirms the save.
- Qt spin boxes change their value on scroll: move the pointer off them
before you scroll.
- gLabels edits an object's text in Object properties → Text, not on the
canvas.
- In Grisbi's transaction form Return moves to the next field; only Return
in the last field, Notes, records the transaction.
- ghostwriter autosaves a new document as `~/Documents/untitled-1.md`: name
it with File → Rename, not Save As; Ctrl+Shift+S does nothing, and Enter
after a checklist item starts another `- [ ]` item.
- KMyMoney's first launch opens the New File Setup wizard; Finish asks for a
storage type (XML), then a Save As where typing `Documents/<name>.kmy`
works. Its date field is a sectioned Qt editor: double-click the month and
type each part, since Ctrl+A garbles it. A new transaction's payee comes
prefilled and selected. Amounts are saved as reduced fractions, such as
`-1749/20`, so compare them exactly. Untick Help → Tip of the Day → "Show
tips on startup" before `seed`, or the tip appears at every boot.
- QElectroTech: an element dropped from Collections follows the pointer until
a click on the folio places it. Its title shows only the app, so it needs
`state_paths`; its settings are in `~/.qet`.
- Rosegarden: after a dialog its Transport window can take keyboard focus,
so save with File → Save.
- Pencil2D: the Tools dock is too narrow for its buttons, so use shortcuts
(B for the brush); double-click a layer's name to rename it.
- Umbrello: the title shows no unsaved mark, so confirm a save with a probe
checkpoint. An Untitled document autosaves `~/autosave.xmi`: delete it
before `seed`. Add attributes from the tree: right-click the class → New →
Attribute.
- Hydrogen: the computer has no audio device, so Hydrogen falls back to a
null driver and its BPM field snaps back to 120 within about 100 ms.
Before `seed`, set Preferences → Audio System → ALSA with device `null`
and click "Apply and restart output", which `~/.hydrogen/hydrogen.conf`
keeps for the seed; also set autosaves per hour to 0, and dismiss the
development-version dialog with "Don't show again". `pulseaudio` as an
extra package does not help.
- Poedit cannot add entries by hand. Use File → New, pick the language and
save to `Documents` first (Sources Paths stays locked until then), then
Properties → Sources Paths → + → Add Files with a source such as
`/usr/lib/python3.12/argparse.py`. It writes `fr.mo` beside the `.po`, and
the language box's autocomplete popup takes the first OK click.
- CherryTree: its first-run autosave "Choose Storage Type" dialog swallows
keys, so save once early as `.ctd` and untick autosave.
- VSCodium (its tarball, with `extra_packages: ["libnss3",
"libasound2t64"]`) runs on outside REA's reach, so `stop_build_capture`
fails to clean it up: after `end`, quit it with Ctrl+Q, then stop. Enter
accepts its word suggestions, so press Escape after typing a line.
- Glade: a new widget's id is empty until you set it, and a second save
writes a `.glade~` backup, which shows in the draft as an unrelated
change. A backup present at `seed` is seeded with the document, so trash
it before `seed`. The draft names a button's `<child>` by the id
its widget carries, `[name=child][>object@id=submit_button]`, which moving
the button keeps; give the widget an id before `seed` so it can.
- VYM: the title never names the map, so pass `Documents/<map>.vym` in
`state_paths`. Its settings live in `.config/InSilmaril` and are written
only at a clean exit. It writes `<map>.vym.lock` while a map is open, so
close the map before `seed`. Press A only after clicking a branch on the
canvas: keys typed in the tree editor run other shortcuts.
- KmPlot: Create → Cartesian Plot, then edit the formula in the Function
tab and press Return. It stores minus as U+2212 (`−`). Its file:// title
still names the file.
- RedNotebook keeps each month in `~/.rednotebook/data/YYYY-MM.txt`, YAML
read as text, folded at about column 80: pick check phrases the fold keeps
on one line.
- Qt5 apps saving with QDom, such as KmPlot, write attributes in a random
order in each process, so check their XML by element, never by whole line.
- Gnumeric's desktop entry is `org.gnumeric.gnumeric`. Its Save As dialog
runs off the 1280×800 screen: type `~/Documents/<name>` in Name and press
Return. Rename a sheet with right-click on its tab, then Rename….
- Parley's title shows the collection's title, so name the file in
`state_paths`. File → New opens the new collection's Properties dialog:
confirm it with OK, since Cancel keeps the old collection and its autosave
lock open. A new collection starts with 15 blank entries: delete them.
Pick languages from the dropdown; a typed name keeps the previous locale.
While a document is open KDE keeps an autosave and its `.lock` in
`~/.local/share/stalefiles/parley/`, and there is no File → Close: before
`seed`, use File → New, then reopen the collection from Open Recent.
- Sigil: pass `extra_packages: ["libqt5svg5"]`, or its SVG toolbar icons are
blank. Its title names the book, so it needs no `state_paths`. It unpacks
the open book into `.local/share/sigil-ebook/sigil/workspace/Sigil-XXXXXX/`
and deletes it on exit, so never name the whole `sigil-ebook` folder: that
seeds the stale copy.
- What the app saved under the home before `seed`, such as a document in
`~/Documents` or its settings, is part of the seed. A file you move to the
Trash is left out, and the export lists it in `limitations`. Trash a stray
file rather than starting over. JabRef 3.8 writes `<file>.bak` and
`<file>.sav` beside a `.bib` it saves; in the owned folder they are seeded
and show in the draft as noise, so trash them before `seed` if they should
not ship.
- Dismiss first-run dialogs, such as a welcome screen, before `seed`. A
choice the app saves only later, such as Inkscape's "show this every
time", may not reach the seed, so expect the dialog in the witness too.
- Some apps write their settings late: Freeplane writes
`~/.config/freeplane/1.7.x/auto.properties` only once Preferences is
confirmed, and a seed without it reopens its documentation map. Before
`seed`, open the app's preferences and confirm them, then read a probe
checkpoint's `changed_files` to see the settings file written.
- Before `seed`, click into the document to clear any selection the app
would save: Gnote saves its cursor and selection with the note.
- An app whose state folder is named after it, such as
`~/.local/share/gnote`, or after it and its version, such as TreeLine's
`~/.treeline-3.1`, is found without `state_paths`.
- TreeLine: File → New opens a template window. Book List's CATEGORY makes
AUTHOR children by default; change it under Data → Configure Data Types →
Default Child Type. A new child opens an inline rename showing "", so
press Escape and fill its fields in the Data Edit tab. Ctrl+A adds a child
node even while a field has focus. Clicking a node already selected starts
an inline rename, so Ctrl+X then cuts its text: cut a node from its
context menu. It saves nodes sorted by their
generated uid1, so their order changes from run to run. The draft names a
new node by its format and a field of its `data`,
`/nodes/[format=BOOK][>data~1Title=The Silk Roads]`, and checks its other
fields through that, so give each new node a title no other node has.
- After an app crashes, capture again on a fresh computer, so the seed
carries no crash-recovery state, such as MyPaint's "Recover interrupted
work?".
- Give what the task changes a stable name where the app has one, such as an
id in Inkscape's Object Properties, so a check can select it.
- Expect apps to split text into runs: Writer's revision ids split an
edited paragraph into spans and may give it an automatic style such as
`P1`, and AbiWord wraps each insertion in `<c>`. Check such text whole
with `text_equals` (see Write the task), and find the paragraph by
something the edit keeps, such as AbiWord's `xid`.
- Freeplane's first launch opens a documentation map: close it with Ctrl+W.
Its Ctrl+N asks for a template.
- MyPaint 2.0.1 crashes on brush strokes on the build computer: build its
seed with flood fill and layer operations.
- GnuCash's Ctrl+S on a new book opens a Save dialog with no format choice:
use File > Save As to pick XML. Close the book (File > New File) before
`seed`, so its `.LCK` lock is not seeded.
## Build the seed and the edit
1. `start_build_computer` with
`{"source": {"kind": "request", "desktop_entry": ..., "install": ...}}`.
The first start of an app builds its image, and starts at the same time
on this host wait for each other; either can take minutes. A failed
start returns the build's error: fix the request, or stop when the
install cannot work, and close any `computer_id` the error names.
2. `start_build_capture`. It returns once the app's window is open. A
`package_lists_error` may stop the capture finding the package's
sources; the export says when that matters. When the app exits before
its window opens, the error gives its exit code and the end of its
stderr, which may name what it lacks, such as an icon loader or a Java
runtime: close the computer and start a new one with that package in
`extra_packages` (see Choose the install), or stop as for an install
this flow cannot do.
- REA counts a file as the app's document only when the app's window
title shows its file name or `~/` path. When the title shows something
else, such as MuseScore's score title, pass the path you will save the
document at in `state_paths`, relative to the home:
`{"computer_id": ..., "state_paths": ["Documents/MuseScore3/Scores/Morning_Song.mscz"]}`.
It need not exist yet. Add the app's settings folder when its name
differs from the app's, such as `.config/MuseScore`, so the seed keeps
the dismissed first-run dialogs. Do not pass the document as a launch
argument to make REA see it: `state_paths` is the way. gLabels' title
leaves out the file's extension and Grisbi's shows the entity's name,
so both need it.
- An app whose document is a folder of pages, such as a Zim notebook
(`Notebooks/Notes`, a `.txt` file per page), never shows a file in its
title: pass the folder in `state_paths`. Every page in it is then a
document, so the bundle opens none at launch and the app starts where
it keeps its place; check the page the task changes.
3. Make the seed through the app and save it from the app under
`~/Documents`: build the document in the app, or write its source where
the app edits its format as text, then save it again from the app. Make
it look real but synthetic: the thing the task changes, plus a few things
the change must leave alone. Keep it to one document: the bundle's app
opens the seed's document at launch only when there is exactly one, and
otherwise starts on its own screen. For a starting file of the requester's,
read it here and rebuild it in the app with synthetic data in place of
personal data; REA cannot copy a file into the computer.
- An app that keeps its own data and saves after every change, such as
Osmo's contacts in `~/.local/share/osmo/contacts_records.xml`, has no
document: that state is the seed. Build it in the app and mark `seed`
once the app has written. To see whether it has, mark a throwaway
checkpoint, such as `probe-1`, and read its `changed_files`: up to ten
files that changed since the checkpoint before, those a window title
named first, then those under `state_paths` and those the app held
open, caches last, with `more_changed_files` counting the rest.
- The app's own compressed format works when it is gzip, such as
Gnumeric's `.gnumeric`, Dia's `.dia` or Inkscape's `.svgz`: REA reads
the document inside, and checks point into it as into the same
document uncompressed. Compression may be off by default: Dia's
"Compress diagram files" is unticked, which REA reads as plain XML. A format REA reads as binary, such as one
compressed with xz, has no view: save in a format the app offers that
REA reads, then reopen the file once and check that a plain Ctrl+S
keeps that format. When the task is about the app's own binary
format, such as GIMP's `.xcf` or Blender's `.blend`, keep it and check
it through a reader (see Read a binary file with a reader).
4. Only once the seed is saved, `mark_build_checkpoint` with name `seed`.
5. Make the change the task asks for, as a user would, and save it.
6. Only once the change is saved, `mark_build_checkpoint` with name `end`.
For more tasks in this environment, make each next task's change on top
of the last as a user would, save it, and mark `end-<task name>`, such as
`end-add-bread`. Each task is checked at its own end, so its checks must
not read what a later task changes, and a solving agent starts each from
the seed, so a task must not need an earlier one done first.
Then, for each task you can, make a near miss of its change on top, a
plausible wrong edit such as a typo or a wrong number, save it and mark
`near-<task name>`: the export refuses a task whose near miss passes every
check. Take the near misses after the last end, since each later draft
starts from the end before it.
7. `stop_build_capture`. Read `draft.files` (each file that changed, with
its net change and its `values`), `draft.task.checks` (suggested
checks), `draft.guards`, `draft.notes` and `limitations`; each
`later_drafts` entry is the same for one later task, from the end
checkpoint before its own, so it shows only that task's change. When
`draft` is null, read `draft_refusal` first: a missing `seed` or `end`
checkpoint means capturing again on a new build computer, while a failed
draft command names its own cause to fix. If a declared secret was used,
automatic drafting is withheld: write the task manually and export it.
When `draft.files` is empty,
`draft.notes` says why:
- `no_change`: nothing was saved between `seed` and `end`. Capture again
on a new build computer, and save before marking `end`. When the app
keeps the task's state only on screen, such as a calculator's result,
judge the screen instead (see Judge the screen).
- `unowned_changes` with `no_document`: the app saved your document
where REA did not count it as the app's. Its `files` list what changed,
with their roles; the document is among them. Close the computer and
capture again on a new one with that path in `state_paths`; the export
refuses a check on a file outside the owned paths.
- `uncheckable_changes`: the app saved the change in dconf, as gedit does
its preferences, and no check reads dconf. Saving again does not help:
choose a change the app saves in a file, and capture again on a new
build computer, or judge the screen (see Judge the screen).
- `unreadable_changes`: a dconf database changed but REA could not read
it; `error` says why. This is not evidence the app saved there: report
the error rather than changing the task.
A note with a `file` says why that file's suggestions are thin:
- `by_position`: only position names what changed; its `elements` lack
a naming attribute. Name a record that keeps its fields as child
elements by one of them, as Osmo's contact by
`[name=contact][>first_name=Delphine]`, a JSON record by a field
inside it, `[>data/Title=…]` (`~1` for the `/` inside a pointer), or
an element by an id its child carries, `[>object@id=…]`; count records
with `member_count`, `matching` `[name=contact]`; otherwise check an
ancestor's text, or give the element an id in the app and capture again.
- `view_state_only`: every change is view state, a renumbered id or a
time the app stamps, and its `pointers` are the checks kept. The edit
was likely not saved: capture again and save it before `end`.
- `long_text`: the element's text is too long to check whole; check a
phrase in it with `text_contains`.
- `cut_view` or `unreadable_view`: REA's view holds only part of the
file, or none a check can read, such as a binary format (step 3). For
a binary file, read it with a reader (see Read a binary file with a
reader).
## Write the task
Write `{"name", "prompt", "checks"}` from the draft and what you did, one
per task, each with its own name.
- `name`: lowercase letters, digits and hyphens, not `smoke`.
- `prompt`: what the requester wants, in their words and the app's terms,
starting with the app's name, naming the document as a user would and
ending with saving it: "In Inkscape, change the poster's headline in
Documents/poster.svg to Summer Bake Sale and save it." Name the app: the
solving agent's own shell cannot see the computer's files, and the app's
name sends it to the screen. Leave saving out for an app that saves on its own, after every change
or a few seconds after the last one, such as Osmo or Gnote: grading waits
until the app's files have held for 3 s, so the solving agent need not
wait for the save. A save more than 3 s after the agent stops is still
missed; name such an app in the report. Never say how the file stores it,
and never give the checks.
- Checks are file-based: content checks that prove the edit, never only that
a file exists or changed. Start from the suggestions; keep those that say
what the user did and drop what follows from how the app saves.
- Compare exact values, so a typo or a wrong number fails.
- Read each file's `values` before writing checks: every value the change
added, changed and removed, with `before` and `after` (an uncut scalar, or
a preview cut at 600 characters) at the pointer a check names it by. Copy
the pointer. Copy `after` into `value` only when it is an uncut scalar: a
string without a trailing `…`, a number, or a boolean. Compact XML and a
JSON array or object are preview strings, so a `value_equals` check needs
the structured value from the file's view, and element text uses
`text_equals` or `text_contains`.
Only XML files, archives, JSON and key files have values: a text file's
or SQLite database's `change` already lists its lines and rows.
`refers_to` says what a number or id stands for, such as `category="44"`
naming `<cat key="44" name="Groceries"/>`, or a new KMyMoney
transaction's splits naming their account and payee; use it for the
prompt's words. `alternatives` gives a changed value's pointer by the
element's other names, such as a label beside an id, to pick another key
without another export.
Skip entries whose `note` is `by_position` (no check can use that
pointer) or noise (`view_state`, `renumbered`, `volatile`,
`timestamp`, `upkeep`, `layout`). `values_cut` means a limit left some out.
- Select elements by what they hold, never by position: SVG text by id,
`/root/children/[name=g][@inkscape:label=Layer 1]/children/[name=text][@id=headline]/children/[name=tspan]/children/0`,
or an ODF paragraph by style,
`/entries/[name=content.xml]/view/root/children/[name=office:body]/children/[name=office:text]/children/[name=text:p][@text:style-name=Title]/children/0`.
A selector must match exactly one element, so give the target a unique id
or style in the seed. The draft's pointers already do this: a name-like
attribute such as Scribus's `ANNAME` before a coordinate, and two
attributes together where one is not enough, as a spreadsheet cell's
`[@Col=3][@Row=2]`. Only an element's text is read by position, as
`children/0`.
- When an app may split an element's text into runs, check it whole:
`text_equals` (or `text_contains` for a value inside a sentence) on the
element compares all its text, however it is split. Add
`normalize_whitespace: true` when the app may reflow spaces. Example, an
AbiWord paragraph by its stable id:
`{"kind": "text_contains", "file": "Documents/memo.abw", "pointer": "/root/children/[name=section]/children/[name=p][@xid=6]", "value": "at 3:30 PM"}`.
Point a text check at the paragraph or another element the edit keeps,
never at a run such as AbiWord's `[name=c]`: a reopened document splits
the text into several runs. The draft lists only what changed; an
unchanged element is in `draft.guards` when it is a like sibling of a
changed one, and otherwise use the app's usual numbering, such as another
paragraph's `xid`, and let the export confirm it.
- In an ODS spreadsheet no attribute names a row: the draft names it by its
label cell, `[name=table:table-row][>table:table-cell=February]`. Where no
cell names it alone, name the sheet in the seed, and check its whole
text: `text_equals` on
`/entries/[name=content.xml]/view/root/children/[name=office:body]/children/[name=office:spreadsheet]/children/[name=table:table][@table:name=Q1 Sales]`,
or a stretch of it with `text_contains`. Cell texts join with nothing
between, so a row reads `February23100`.
- For a line added to free text, such as an item on a note's list, the
draft compares the whole text with `text_equals`. This rejects longer or
negated items, but can reject a correct edit with different ordering or
spacing. Report that limitation when the task permits such variations.
- For a note placed by its own `x` and `y`, such as Xournal++'s `<text>`
on a layer, the draft checks its text on the layer with `text_contains`,
which passes wherever the user puts it, and guards each note it kept.
- Some apps keep text in an attribute, one element per run, such as
Scribus's `<ITEXT CH=...>`: add `"text_attribute": "CH"` to `text_equals`
or `text_contains` on the frame,
`/root/children/[name=DOCUMENT]/children/[name=PAGEOBJECT][@ANNAME=Headline]`,
to read its runs' values joined.
- When the draft shows no values or no checks, a scratch export can expose
`envs/<env>/seed/` only after the task has at least one valid check: it must
pass on the captured end state and fail on the seed. Before the first
successful export, use `act_on_build_computer` to open the saved file in an
available GUI text editor or viewer and inspect its actual representation
without changing it. Use the visible saved content and the task's edit to
write a check; an export refusal can also name the end state's value, such
as `is "44", not "1"`. If the file cannot be inspected or expressed by a
supported check, report the limitation instead of guessing a check.
- Where easy, add one check that the rest is untouched, such as another
text's value the edit must leave alone. `draft.guards` offers them at
their seed values, which the end holds too: a field on the nearest like
siblings, one the user sets, such as Planner's `work`, before a default
they all share; the text an addition kept; a text file's lines beside its
new one. Copy one or two into `checks`; they pass on the seed, which is
fine, since the export needs only one check to fail there.
- The draft suggests a decimal the app writes as text, such as `146.83`,
as a number with `tolerance: 0.001`; keep the tolerance for any number
you check that way.
- When the draft shows an XML document's changes as `lines`, REA read it
as text, for example because its DOCTYPE declares entities. Selectors are
impossible there, and `line_count` with `contains` only approximates a
value: `3:30` also matches `13:30`. Bound it with neighbouring text and
name the check as approximate in the report's limitations.
- Some apps save a document reloaded from disk differently from one typed
in the same session: AbiWord wraps each new insertion in its own markup.
Your build's end state hides this, which is why whole-text checks matter;
expect the witness to show the real format.
The kinds and pointer rules are in
[the desktop tasks section](environments.md#pick-the-checks).
### Read a binary file with a reader
When the draft notes `unreadable_view` on a binary document, add a reader to
the task: the app's own headless command, which REA runs in the computer on a
private copy of the file, and whose output a check reads instead of the file.
Write it in the app's scripting mode, print each fact on a line starting
with `REA ` (with the space), and name the copy `{file}`:
- GIMP `.xcf`, layer names, as `lines`:
```json
{
"id": "layers",
"file": "Documents/poster.xcf",
"argv": ["gimp-console-2.10", "-i", "-d", "-f", "-b", "-"],
"stdin": "(let ((image (car (gimp-file-load RUN-NONINTERACTIVE \"{file}\" \"{file}\")))) (for-each (lambda (layer) (display \"\\nREA layer \") (display (car (gimp-item-get-name layer)))) (vector->list (cadr (gimp-image-get-layers image)))) (newline))\n(gimp-quit 0)\n",
"output": "lines"
}
```
checked with `{"kind": "lines_present", "file": "Documents/poster.xcf", "reader": "layers", "lines": ["layer Summer Sale"]}`.
Script-Fu's prompt `ts> ` starts a line, so print a newline before each
marked line, as `\nREA` does. A text layer's text is
`(car (gimp-text-layer-get-text layer))`.
- Blender `.blend`, objects with their type and location, as `json`:
```json
{
"id": "objects",
"file": "Documents/scene.blend",
"argv": [
"blender",
"-b",
"--factory-startup",
"{file}",
"--python-expr",
"import bpy, json; print('REA ' + json.dumps({o.name: {'type': o.type, 'location': [round(v, 4) for v in o.location]} for o in bpy.data.objects}, sort_keys=True))"
],
"output": "json"
}
```
checked with `{"kind": "value_equals", "file": "Documents/scene.blend", "reader": "objects", "pointer": "/Crate/location/0", "value": 2, "tolerance": 0.001}`.
Print every value a check compares as a number or string a check can
name, rounded where the app stores floats.
Blender 4 shows its Quick Setup splash at every launch until its
preferences are saved: with `.config/blender` in `state_paths`, confirm
Quick Setup with Continue, save the preferences (Edit → Preferences, then
Save Preferences in the menu at its bottom left), and read a probe
checkpoint's `changed_files` for `.config/blender/4.0/config/userpref.blend`
before `seed`, so solves start on the scene. Blender often takes the first click on a
menu or a dialog's button only to focus it: click again.
- A format LibreOffice opens but REA cannot read, such as a legacy `.xls`,
converted with `--convert-to`:
```json
{
"id": "cells",
"file": "Documents/budget.xls",
"argv": [
"sh",
"-c",
"soffice --headless --convert-to csv --outdir out \"$1\" >/dev/null && sed 's/^/REA /' out/*.csv",
"sh",
"{file}"
],
"output": "lines",
"timeout_s": 120
}
```
Pass `{file}` to `sh -c` as an argument, as here, rather than inside the
script.
The reader runs without the display, in a fresh home with the app's default
settings, so use the app's headless or batch mode; set `display: true` only
for an app that has none. It must exit 0 within `timeout_s` (60 s by
default, at most 300) and print at most 1 MiB. Pick facts the change sets and
the near miss gets wrong, such as a name or a number, not the app's whole
output.
Give the task the reader in `readers`, and each check through it `reader`
and the reader's `file`. `json` readers serve `value_equals`, `value_absent`
and `member_count`; `lines` readers `lines_present`, `lines_absent` and
`line_count`. The export runs the reader in the build computer on the seed
and the end: when a check through it fails on the end, the refusal shows
what the reader printed, and a reader that fails shows its error and the end
of its stderr. Fix the reader or the check from that and export again.
### Judge the screen
Only when no file shows the change, as when the draft notes `no_change`
because the app keeps its state on screen, write the task's checks as
screen judges: `{"id": "shown", "kind": "screen_judge", "question": "Does the
calculator's display show 8.568?"}`. A model, the judge, answers the question
on a screenshot three times, and the check passes when most answers are yes.
- REA keeps a screenshot at each checkpoint. Mark `seed` with the screen
showing the state before the task, where the answer is no, and `end` with
the change showing, where it is yes: the bundle ships both as the judge's
examples.
- Ask about what the change makes visible, with exact values, so the near
miss is a no: a result, a label, a selected item. Avoid what a window
moved or a cursor blinking can change.
- The prompt asks to leave the result showing, since the judge sees only
the screen when the agent stops.
- A task with screen judges holds only screen judges and no readers. The
export refuses one when a file shows the change, and checks none of its
questions: the witness does, running the bundle's own judge with the
runner's model endpoint (`LITELLM_BASE_URL` and `LITELLM_API_KEY`, or
`[model]` in `.agentenv/config.toml`) on a fresh screenshot at each stage.
Without an endpoint the witness's check fails: say what to set and stop.
- The judge's model is `[model.roles] judge`, else `[model] default`, else
`anthropic/claude-sonnet-5-5`. `agent-env run --judge-model` does not
reach it. Each grade costs about $0.02.
## Export
`export_build_environment` with `tasks` and `directory`, the absolute path of
a new or empty folder here, such as `<home>/rea-builds/poster/bundle`. Each
entry of `tasks` is `{"task": ..., "end_checkpoint": ...}`: leave
`end_checkpoint` out for the task checked at `end`, and give
`end-<task name>` for each later one. The
export seeds what was saved at `seed`, replaces the computer's host name,
`user@host` and home path (a bare user name, such as AbiWord's author
`user`, stays), and checks each task against `seed`, its own end and its
`near-<task name>` when you took one. It refuses a task
unless at least one check fails on the seed, every check passes at its
end and one fails at its near miss, and says which task and why, such as a
pointer that names nothing at the end. A
check that the rest is untouched passes on the seed too, which is fine. On
a refusal, fix the task and export again, into another folder if that one
is no longer empty. The bundle's eval lists every task, so a run of the
bundle runs them all. A refusal naming a secret's handle means a file of the
bundle holds that secret, most often an app's sign-in or licence state in
the seed: do not work around it, but report it and stop. Keep
`seed_files`, `scrubbed_files`, `unscrubbed_files` and `limitations` for the
report.
The limitations name the folders the app wrote that the bundle neither owns
nor seeds, such as `~/.config/MuseScore`, so the app starts without them:
expect its first-run wizard, a tour or a "restore session?" question at every
boot. If the witness shows one, capture again on a new build computer with
those folders in `state_paths`, and dismiss the dialogs before `seed`. Lock
files the app holds while it runs, such as LibreOffice's `.lock` and
`.~lock.<name>#`, GnuCash's `<book>.LCK` or Grisbi's `.<file>.lock`, are
left out of the seed and listed after "Left out of the seed". An app can
still start fine yet refuse every save when a lock was seeded, so close the
document before `seed` where the app lets you (apps other than LibreOffice,
such as Rosegarden, also write `.~lock.<name>#` and autosaves beside a
document, and closing it covers them too), and treat "file is locked"
on a save in the witness as a seeded lock: delete it from the bundle's seed
and witness again in a new computer. In LibreOffice, own the profile
(`state_paths: [".config/libreoffice"]`) for a clean boot, and close the
document before `seed`, then reopen it from Recent Documents to make the
change: the export also leaves out the recovery list LibreOffice writes while
a document is open, which would start the bundle into document recovery. A
limitation naming `~/.config/dconf/user` as unread means the app's dconf
settings, such as a first-run tip you turned off, are not seeded: capture
again on a new build computer. The limitations also name seeded WebKit,
Chromium or QtWebEngine storage and caches; when the witness's boot is clean,
list them in the report.
Keep the build computer open until the witness holds: a task fix exports
again from it.
## Witness
Witness each task in its own fresh computer of the bundle, from the prompt,
as a solving agent would; a computer is witnessed once, so the steps below
repeat for each task, each with its own record folder. Do not replay the build's actions or coordinates, and do not
check between stages to steer the edit: a solving agent never sees the
checks. Give every `check_build_task` the task exactly as exported.
1. `start_build_computer` with
`{"source": {"kind": "bundle", "path": "<bundle>"}}`, adding
`"settings": {"<variable>": "<handle>"}` for each of the export's
`withheld_files`, or the deploy is refused for want of them, and agentenv
keeps that bundle's build failed, so start from a fresh copy of it. The app starts
with the seed's files in place and opens the seed's document when the
seed holds exactly one and the app takes a file. When the export's
`limitations` say it opens no document, open it from the prompt's path,
as a solving agent would. Take a `wait` screenshot first, since the
desktop may not have drawn yet. When the start fails because the
computer never got ready, and its error names lock files in the bundle's
seed, delete them from the bundle and start again.
2. `check_build_task` with `stage: "seed"` and `directory`, the absolute
path of a new or empty folder for the record. A check must fail.
Recording starts. Later stages take no `directory`.
3. Do the task from the prompt with `act_on_build_computer` and save.
`check_build_task` with `stage: "after_edit"`: every check must pass.
Each check waits until the app's files have held for 3 s, as the
bundle's verifier does, so an app that saves on its own needs no wait of
yours. A result with `settled: false` means the files were still
changing at the 15 s bound, and `read_cut: true` that a slow read was
still running there.
4. Make a near miss on top of it: a plausible wrong edit, such as a typo or
a wrong number, and save. `check_build_task` with `stage: "near_miss"`:
a check must fail.
5. `act_on_build_computer` with `action: "reset"`, then `check_build_task`
with `stage: "after_reset"`: a check must fail again.
6. The last stage ends the witness: `witness.record` has the verdict, the
path of `witness.json` and the recording's segments in `directory`.
`close_build_computer` on the witness computer.
A stage that does not hold ends the witness as `failed`. Read each check's
result and decide:
- `after_edit` failed though the saved file is right: the check is brittle.
Fix it, export again and witness in a new computer.
- `after_edit` failed because your edit was wrong: witness again in a new
computer; a computer is witnessed once.
- `near_miss` passed: the checks are too loose. Tighten them, export again
and witness again.
- `after_reset` passed: the reset did not restore the seed. Report it as a
limitation.
Stop after three witnesses of a task and report what still fails.
## Measure
Only if the requester asked for it, or by the headless default above. If
they wanted to review the tasks first, show them each prompt and checks and
wait for their approval. Then `measure_build_task` each task with `bundle`,
`task`, `model` and `budget_usd`, its share of the budget. It runs the task
in fresh computers, 3 times, then 2 more unless they passed none or all, and
keeps a task that passes 1 to 3 of 5 (`band: "in_band"`). It stops before a
solve the largest cost so far would take past the budget, and writes the
record into the bundle's `difficulty.json` and README. The solver needs a
model endpoint where REA starts: `LITELLM_BASE_URL` and `LITELLM_API_KEY`, or
`base_url` and `api_key` under `[model]` in `.agentenv/config.toml`; when the
measure says a solve did not run for want of one, tell the person what to
set and stop. Read the result's `failures_per_check` and each solve's
`failed_checks` before calling a task hard: a check that fails in most failed
solves while the app shows the right change is brittle, so fix the check,
export and witness again, rather than count it as difficulty.
## Harder tasks
When the requester wants tasks worth evaluating or training on, harden a
task that came out `too_easy` and measure it again, within the budget. The
solver sees and fixes its slips on screen, so one edit with the value in the
prompt is solved almost every time; so far, on TreeLine with Sonnet 5.5, so
were one more dialog, a conditional move of seven records, and ten
corrections read from a second document and applied across the list. Try,
one at a time, measuring each:
- A longer chain of exact edits than that, each a value the checks compare
whole, so one slip in the chain fails the task.
- Facts read in one view and used in another: values the solver must read
from a second document the app opens, or from another record, rather than
from the prompt, such as "the same year as The Quiet Glacier's".
- Distractors beside each target: records with near-identical names or
values, and a guard check on each that it stays as it was.
- Mistakes that do not show once saved: a value the app displays rounded or
formatted unlike what it stores, or an edit the app keeps only when its
field loses focus.
Stop when the budget left cannot cover 3 solves of the hardened task, and
report the bands measured.
Each hardened task stays sound: its checks still fail on the seed and pass on
its end, its near miss fails, and another valid way of doing it passes, so
check values, never the order or method the solver used. Put the second
document in the seed. Count records with `member_count` and check each
value by a selector that names its record, since no check joins two records.
## Close and report
`close_build_computer` on every computer still open, also after a failure.
If a close fails, call it again; if it still fails, tell the requester what
is left.
Report:
- the bundle path and the environment name;
- each task's name, prompt and end checkpoint;
- each check, and why it proves the edit and fails the near miss, and each
reader's command;
- each task's witness stages with their results, the verdict, and the paths of
`witness.json` and the recording;
- the seed's contents: `seed_files` and what each holds;
- `scrubbed_files` and `unscrubbed_files`; an unscrubbed file still names
the host or user, so it needs review before sharing. `recompressed_files`
are gzip seeds whose headers were cleared, their content unchanged: give
their count;
- limitations: the capture's, the export's and anything you could not do;
- each measured task's band, passes of finished solves, turns, cost and
failures per check, from `difficulty.json`;
- the build time and the questions you asked.
Never publish or share the bundle yourself.
@@ -0,0 +1,399 @@
# Agent environments from a live application
Use this when the user asks for an agent environment (an AgentEnv bundle) of a
web application: a folder `agent-env` deploys, whose MCP tools answer from what
one recording of the application observed and serve the writes that recording
confirmed. The workflow records the application once with
`capture_browser_scenario`, checks what the environment would serve, records
again if a hint names a gap, then writes the bundle with
`export_environment_bundle`. For a HAR the user already has, skip to
[export](#export-and-hand-off) through `inspect_web_network_capture`, or use
`rea environment-build <capture.har> <dir>`. For a task in a desktop
application's exported environment, read
[its section](#tasks-for-a-desktop-application-environment).
## Before recording
- Record only against an instance the user says is disposable or seeded for
this purpose: every write step creates, edits or deletes its data. If the
user has not said so, ask before any scenario that writes. REA does not
start, seed or reset applications.
- Credentials come from environment variables the user names. Declare each as a
scenario secret, `{secret_id, environment_variable}`; the variable must be set
in the environment REA's MCP server started with. Never ask for a password in
chat. Declared secrets are redacted as the capture records.
- Map the application first with `inspect_web_page` (and `capture_web_screenshot`
when layout matters): the login form's locators, and the API calls behind the
pages that list and edit the entities the user asked about.
- `inspect_web_page` cannot sign in. To see pages behind sign-in, run a short
`capture_browser_scenario` that signs in and opens them, with
`"capture": {"after_each_step": ["accessibility"]}` (`"dom"` for markup).
Read a step's tree with `inspect_analysis_view`, `view: {kind:
"browser-scenario", collection: "text", step_index, artifact:
"accessibility", offset: 0, limit: 2048}`, paging with `offset`. Capture
options are only `after_each_step`, `at_end`, `events`, `network` and
`limits`.
## Record a session an environment can learn writes from
One `capture_browser_scenario` records the whole session. Start from this
shape, with the application's own locators, paths and headers:
```json
{
"browser": { "mode": "launch", "executable_path": "/usr/bin/chromium" },
"start_url": { "url": "http://127.0.0.1:3000/login" },
"secrets": [
{ "secret_id": "admin_password", "environment_variable": "APP_PASSWORD" }
],
"capture": {
"network": {
"request_body": true,
"response_body": true,
"header_values": true
}
},
"actions": [
{
"step_id": "user",
"action": "fill",
"timeout_ms": 15000,
"locator": { "kind": "css", "selector": "input[name=username]" },
"value": { "source": "literal", "value": "admin" }
},
{
"step_id": "password",
"action": "fill",
"timeout_ms": 15000,
"locator": { "kind": "css", "selector": "input[name=password]" },
"value": { "source": "secret", "secret_id": "admin_password" }
},
{
"step_id": "sign-in",
"action": "press",
"timeout_ms": 15000,
"locator": { "kind": "css", "selector": "input[name=password]" },
"key": "Enter"
},
{
"step_id": "signed-in",
"action": "wait_for",
"timeout_ms": 15000,
"locator": { "kind": "css", "selector": "nav a[href='/settings']" },
"state": "visible"
},
{
"step_id": "folders-1",
"action": "request",
"timeout_ms": 15000,
"method": "GET",
"path": "/api/folders",
"expected_status": [200]
},
{
"step_id": "new-folder-1",
"action": "click",
"timeout_ms": 15000,
"locator": { "kind": "role", "role": "button", "name": "New folder" }
},
{
"step_id": "folder-title-1",
"action": "fill",
"timeout_ms": 15000,
"locator": { "kind": "css", "selector": "input[name=title]" },
"value": { "source": "literal", "value": "Scratch folder 1" }
},
{
"step_id": "create-folder-1",
"action": "click",
"timeout_ms": 15000,
"locator": { "kind": "role", "role": "button", "name": "Create" },
"save_response": {
"method": "POST",
"path": "/api/folders",
"save": [{ "name": "folder_1", "json_pointer": "/id" }]
}
},
{
"step_id": "read-folder-1",
"action": "request",
"timeout_ms": 15000,
"method": "GET",
"path": "/api/folders/{saved:folder_1}",
"expected_status": [200]
}
]
}
```
Give each step that accepts `timeout_ms` a deadline. Without one, a step waits
until it completes or the scenario is cancelled, so a locator that never
matches hangs the capture and the client with it. The `wait_for_timeout` pause
instead uses `duration_ms` and does not accept `timeout_ms`. Match `wait_for`
locators to exactly one element.
1. Sign in through the UI: `fill` the user and password fields (the password
as a secret reference), submit, then `wait_for` an element only a signed-in
user sees.
2. Read with `request` steps: each collection the environment needs, twice
before any write, so fields that change on their own show up; then each
object a list shows by id, up to ten per collection. An object known only
from a list that leaves out fields its own read shows cannot be read by id
or updated in the environment.
3. Write the way the application's UI does: fill its forms and click its
buttons, so each request carries the fields the UI sends, including ones
the server would otherwise derive. Give the click or key press that submits
a create `save_response` to keep the new object's ID, then `goto` its page
(`/items/{saved:item_1}`) or read it with a `request` step. Never guess an
ID, and never create a probe object to learn the next one. Write with
`request` steps instead only when the UI cannot reach the operation (an
API-only resource, or a UI that does not call the JSON API), or when coverage
withholds the UI-shaped write as `unexplained-change` or `contradicted`;
that counts as one of the two re-recordings. A `request` step write that
coverage withholds that way usually lacks fields the UI sends: record it
through the UI instead. If a journey cannot be scripted, leave the write
unserved and report the limitation.
Record two numbered scratch objects per write type,
with varied free text. After each write, and before the next one, read the
object back by id and then its list: create 1, read 1, list, create 2,
read 2, list; never several writes before their reads.
4. Leave more than a second between two creates and between a create and its
first edit (`wait_for_timeout` with `duration_ms: 1100`), so lists sorted
by creation time show them in order.
5. Update both objects and read each back, changing the fields a person
would change (a name, an email address, a status, a due date) rather than
only a description: a served update accepts only the members a recorded
update stored. Run an action that turns something on or off (star,
bookmark, archive) twice in a row on one object, reading it back after
each: the second call shows whether it sets or flips the field, and the
environment serves the action only once a call has shown it. Delete both
and read each back (expect 404, or the object marked deleted) and the list.
6. Vary what the two creates send, however you write them: the first every
field the application takes, the second only what a person must give (its
title or name), with the other fields left out or empty. A served create
requires each member both creates filled in and lets a caller leave out one
a create left out or sent empty, so an agent that names only a title is not
refused for fields it had no reason to set.
About `request` steps:
- `path` is on the start URL's origin; the page must be on that origin.
- Header values are literals, declared secrets, a cookie the browser holds
(`{source: "cookie", name}`), a local storage entry
(`{source: "local_storage", key, json_pointer}`), a saved value
(`{source: "saved", name}`), or Basic credentials
(`{source: "basic_credentials", parts: [username, password]}`, sent as the
base64 of `username:password` and redacted), with an optional `prefix` such as
`"Bearer "` or `"Basic "`. Each credential part is a literal, secret, saved
value, cookie or local storage entry, so a header the frontend builds from a
login token (the username and the token) can be copied too; the password is
a secret or saved value, never a literal. Copy the
headers the application's own requests send (a CSRF header such as
`X-CSRFToken` from the `csrftoken` cookie, a bearer token). Values read from the page are redacted from records made after they
are read, not from the login response that returned them, and values
shorter than 8 characters are not redacted at all.
- `save: [{name, json_pointer, secret}]` keeps a string or number from the
step's JSON response, such as the created object's `/id`, for later steps;
`secret: true` redacts it, as for a token the API returns, so mark every
saved token, key or session value secret. This also protects the selected
source response before its body or headers are retained. Compressed or
unknown-size UI responses cannot supply saved values. On a `click` or `press`,
`save_response: {method, path, save}` keeps values from the response to the
new request observed while the step waits (`{*}` in `path` matches one segment).
Matching does not prove UI causality: choose a path distinct from polling;
multiple observed matches fail as ambiguous. The step
fails unless a 2xx JSON response arrives before its `timeout_ms`. Later
steps send a saved value as `{saved:name}` in a request `path` (and its
query) or a `goto` URL; in the `json` body, where a string that is only
`{saved:name}` keeps the saved JSON type; or as `{source: "saved", name}` in
a header or credential part.
- Reading a page value makes later bodies costlier to keep: after a CSRF
token, bodies over about 47 KiB are kept as `unavailable`. When the API
returns larger responses, raise `capture.limits`
(`max_network_body_allocation_bytes`, `max_total_event_allocation_bytes`).
- Request steps follow no redirects: a redirect fails the step, and
`expected_status` cannot name a 3xx status. Use the path the redirect names,
such as the one with the trailing slash.
- Set `expected_status` on every step, so a refused call fails its step and
the later steps report not having run, instead of recording read-backs of
nothing.
Keep it to about 60 to 160 requests. A scenario with no `request` step is
recorded exactly as before.
The capture returns its whole Evidence, every recorded body included, which is
often more than a client shows inline. Before checking coverage, confirm that
every entry of `normalized_result.steps` has `status: "completed"`; if the
client saved the result to a file, search it for `"status":"failed"` and read
that step's `error`. A failed sign-in or write cancels every step after it.
Use the result's `evidence_id` for coverage and export; do not paste the
Evidence back into another tool.
## Check coverage
`inspect_analysis_view` with `source: {kind: "retained-evidence",
evidence_id}` (the capture's id) and `view: {kind: "environment-coverage",
template_rules: ["after-uid-literal", "opaque-token", "sibling-variance",
"prefix-variance"]}`. It reports read tools
answered from the state or replayed (`reads.recorded_reasons`), the served write
tools, and each withheld write with its `reason` and `hint`. It does not apply
the serve-time catalog and state size checks; the export reports those.
Re-record a withheld write at most twice, and only for a recording gap. Stop at
once when the reason is application behaviour, and report the write as
withheld with its hint:
| Reason | What to do |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `no-state-target` | Check the path template and recorded views; record missing reads. The route form may not be supported as a mutation target. |
| `no-observation` | Re-record: read the entity back by id after the write; read it first for an update or delete. |
| `unconfirmed` | Re-record: read the entity and its list after the write. |
| `incomplete-entities` | Re-record: read the entity through every view that lists it. |
| `unexplained-change` | Re-record once with a second, different call of the same write. |
| `no-call` | Re-record once with a write that succeeds. |
| `side-effect` | Stop: the application changes other entities. |
| `contradicted` | Stop: a later read disagrees with the modelled effect. |
| `body-unusable` | Re-record a UI write once with `request` steps (its body was not exposed); else stop. |
Use new scratch names on each attempt, and delete what an earlier attempt
created before recording again (through the UI, or a `request` step with the
id that capture shows): the environment starts from what the recording first
reads, so a leftover object becomes seeded data that write tasks then find.
New browser scenario captures include request times, so a fully timed capture
can learn a write clock and serve updates that stamp modification times. Older
captures or ones missing a request start keep record order and learn no clock.
When coverage reports that limitation, record again with the current version
before concluding that the timestamp change cannot be served.
## Export and hand off
`export_environment_bundle` with `source: {kind: "retained-evidence",
evidence_id}` (or `{kind: "evidence-file", path}`), an absolute
`output_directory` that does not exist yet and whose parent does, and a `name`
(required when the application runs on `localhost` or an IP address). It returns
the coverage again, `tool_count`, the written files and warnings; `dry_run:
true` reports without writing. The bundle installs `rea-agents` of this REA's
version from the registry; when the user runs an unreleased REA build, pass the
`package_tarball` they give you (a packed `rea-agents` tarball) instead.
Tell the user how many tools answer from the state, which writes are served and
which are withheld and why, and the run command (`agent-env run <folder>`). Say
that the bundle ships the recorded responses and the capture's local paths
(for a browser scenario, the browser executable's path), and
list any `credential-named-members` warning pointers for review. Never publish
or share the bundle yourself.
See
[agent environments](https://github.com/morluto/rea/blob/main/docs/agent-environments.md)
for what the state, replay and the bundle establish, and
[evidence workflows](evidence-workflows.md) for the manifest and state views.
## Tasks for a desktop application environment
Use this when the user has a folder from `rea capture-desktop-application` and
wants a task for the environment `rea export-desktop-application` makes from
it: a prompt for a computer-use agent and checks that grade what the agent
left in the app's files. You write the prompt and the checks; REA validates
the task against the capture and exports it. These commands are CLI only. To
build the environment and its task from a request instead, follow
[desktop builds](desktop-build.md).
### Describe the change
Run `rea describe-desktop-application-task CAPTURE --format json`. Read:
- `owned_paths`: the only paths whose files the app serves, so the only files
a check can read.
- `files`: each file there whose state differs between the capture's first
and last version, with its `role`, the `baseline` and `final` versions and
the net `change`. Text changes list each added and removed line once, and
`line_counts` gives lines found at both ends whose count changed.
- `values` in each of those files: what the change added, changed and
removed, `before` and `after`, at the pointer a check names it by, with
`refers_to` naming the element a key such as `category="44"` stands for.
Skip entries noted `by_position` or as noise. Only XML files, archives,
JSON and key files have values: a text file's or database's change
already lists its lines and rows.
- `task`: a draft whose `checks` are suggestions from the net change of the
documents the user worked on, or of the app's own state when no document
changed.
- `guards`: optional checks that the rest kept its seed values, such as a
like sibling's field or a text file's neighbouring lines; add one or two
to show the rest is untouched.
- `notes`: why `files` is empty, or why a file's suggestions are thin, such
as `by_position` when no attribute names the changed element.
### Write the prompt
Say what the user wants done, in their words and the app's terms, naming the
app: "In Mousepad, add buy milk to my to-do list and save it". A solving
agent's own shell cannot see the computer's files; the app's name sends it to
the screen. Never say how the file stores it: no
paths the user would not name, line formats, XML, keys or SQL, and never the
checks. Replace the draft's placeholder; the export refuses it.
### Pick the checks
Checks must express only the observed change:
- Keep a suggestion only if it says what the user did. Drop those that follow
from how the app saves: a format rewrite, renumbered lines, counters. A
LibreCAD drawing gaining four holes was rewritten from DXF R12 to 2007, and
the draft suggests counts of `SECTION`, `ENDSEC` and `CIRCLE`; only
`CIRCLE` equals 9 is the user's change.
- Never check unchanged content, whole files, or values the app stamps:
times, ids it assigns, window sizes. REA refuses ISO 8601 and `asctime`
date-times (`Thu Nov 5 09:20:31 2026`); the
rest is your judgment.
- Prefer a few robust checks: whole lines over positions, rows by meaningful
columns over generated ids, `at_least` when the user may add more.
Kinds: `file_present` and `file_absent`; `lines_present` and `lines_absent`
(`lines`); `line_count` (`exact` or `contains`, and `equals`, `at_least` or
`at_most`); `value_equals` and `value_absent` (a JSON `pointer` into the view,
such as `/groups/GROUP/KEY` for a key file); `text_equals` and
`text_contains` (an XML element's whole text at a `pointer`, optional
`normalize_whitespace`); `row_present` and `row_absent` (`table`, `where` by
column) and `row_count`. Each names its `file` as a path under the home. A
document saved as a zip archive, such as FreeCAD's `.FCStd`, reads as its
entries: point into an entry's view, and name array members by selector
rather than position, as
`/entries/[name=Document.xml]/view/root/children/[name=Object][@name=Hole]`,
so the check survives an app version that writes them in another order. A
gzip-compressed document, such as Gnumeric's `.gnumeric`, reads as the
document inside: point into it as into the same document uncompressed. Give
`value_equals` a `tolerance` to compare a number the app writes as text, such
as `"5.0000000000000000"`, with a number. Check what the user wrote in a
document with `text_equals` on its paragraph element, not `value_equals` on a
text node: apps such as AbiWord and Writer split a paragraph into runs as they
save, so its first text node holds only part of it; use `text_contains` for a
value within a sentence, such as a time. A binary document, such as GIMP's
`.xcf`, has no view: give the task a `readers` entry, the app's own headless
command printing `REA ` lines, and a check naming it in `reader`, as
[desktop builds](desktop-build.md#read-a-binary-file-with-a-reader) shows. The
[desktop application tasks](https://github.com/morluto/rea/blob/main/docs/desktop-application-tasks.md)
guide has the exact rules.
### Export the task
Write the task as JSON with a `name`, the `prompt` and the `checks`, then run
`rea export-desktop-application CAPTURE DIRECTORY --task TASK.json`. REA keeps
the task only when its checks fail on the seed and all pass on the captured
end state. A refusal names the check: one that fails on the end state does not
match what the user left; "every check already passes on the seed" means no
check captures the change. For several tasks from one capture, repeat
`--task` and name each later task's end with `--task-end NAME=CHECKPOINT`.
When the export refuses an application that no apt repository installs, ask
the user for the `.deb` file they installed it from and capture with
`--package-file`, then ask where the computer may download that file and pass
it as `--package-url`. Never fetch a vendor's download page yourself: such
sites often refuse automated downloads, and the copy and its licence are the
user's to choose.
Tell the user the task's name, prompt and checks, and how to measure a solve
rate: run `agent-env run DIRECTORY --task NAME --agent claude-code` with
`--model MODEL` three times and count the runs reported `passed`. The runs
need a model endpoint: `LITELLM_BASE_URL` and `LITELLM_API_KEY`, or
`base_url` and `api_key` under `[model]` in `.agentenv/config.toml`. Do not run
agents unless asked. The bundle's seed is the user's real data: keep it local.
@@ -0,0 +1,176 @@
# Evidence, comparison, and verification workflows
Evidence-producing tools return the complete Evidence record directly in MCP
`structuredContent` and CLI JSON. Read `normalized_result` for operation data,
and cite `evidence_id` when another tool or your explanation needs a stable
reference. Preserve authority, limitations, coverage, and residual unknowns. If a result
is incomplete or paginated, continue only when the remaining data matters to
the task; do not fetch a bundle or resource merely to read a result already
returned inline. When complete `inspect_binary_layout` or
`analyze_javascript_application` Evidence is already retained, call
`inspect_analysis_view` for a summary, one section/symbol/module, or a stable
page instead of repeating the producer.
For any logical Evidence source that advertises retained references, pass
`{kind: "retained-evidence", evidence_id}` instead of copying the complete
record back into a call. The same contract covers individual, optional, array
and nested sources across native, process, JavaScript, managed, mobile and WASM
workflows. Sources can independently mix inline records and exact references.
Resolution preserves the source's identity, provenance, coverage, partial facts
and unknowns; it neither reruns analysis nor makes incomplete facts complete.
Keep domain role and identity requirements: a native dossier cannot substitute
for a managed member observation, for example.
When starting from a summary or inspection page, use its
`normalized_result.parent_evidence_id` to reference the complete source.
The view's own `evidence_id` names
only that view. `compare_artifacts` explicitly supports artifact summary inputs
and resolves their parents; do not assume every consumer does. When a workflow
follows source links, keep those linked records available too.
MCP references belong to the current connection's ledger. If the ID is known,
pass it directly; no discovery call or provider setup is needed. If it is missing,
import the exported bundle or supply complete inline Evidence. Do not retry an
unchanged reference or rerun a producer merely to move an existing record.
Export before closing the session when the source must survive it. For CLI JSON
workflows that advertise `--evidence-bundle`, the same reference JSON resolves
against one explicitly selected authenticated bundle:
```bash
rea trace-application-feature request.json --evidence-bundle /absolute/path/evidence.json --json
```
This includes JavaScript traces/comparisons and runtime reconciliation, managed
reconstruction/native verification/graph projection, Android/Apple/Harmony graph
projection, WASM interface comparison and retained analysis views. The managed
member comparison CLI still takes two assembly paths; process comparison takes
two capture Evidence JSON paths. Check command help for the installed version.
An input bundle grants no implicit file lookup: `evidence-file` and historical
graph file selectors are accepted only by contracts advertising them. See the
[CLI reuse guide](https://github.com/morluto/rea/blob/main/docs/getting-started/cli.md#import-export-and-compare-evidence).
Completed `inspect_artifact` inventory also supports a summary and
`view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json", "main.js"]}`.
Omit `logical_paths` to page all available occurrences; `[]` selects none.
Other inventory collections are `nodes`, `edges` and
`integrity_contradictions`. Keep `source_complete` separate from page exhaustion:
a missing match in partial source Evidence does not establish absence. Use the
returned parent Evidence ID for subsequent views and export before closing the
session when the source must survive it.
When `tools/list` advertises `inspect_artifact.detail`, request `"summary"` to
retain its complete source and receive that view immediately. Without that
advertised choice, keep the existing complete call. CLI inspection returns
portable complete Evidence; save it before projecting a selected inline view.
For native procedure/name/string inventories, select `detail: "summary"` when
the connected tool advertises it and the task needs counts or selected rows.
Use its parent Evidence ID with
`view: {kind: "native-inventory", offset: 0, limit: 32, fields: ["value"]}`.
Search tools retain the existing pattern/mode/case/document selection. Omit
`fields` for complete records; `record.address` and `source_index` remain on
selected rows. Selecting `value` keeps its string/decoding qualifications.
Parent Evidence ID plus source index preserves same-address
alternatives. Follow `coverage.next_offset` only as far as the question needs;
exhausting retained rows does not establish complete program discovery.
`batch_decompile` returns every body unless you select `detail: "summary"` when
the connected tool advertises it. Use its parent Evidence ID with
`view: {kind: "batch-decompile", collection: "items"}` for body-free rows and
exact `where.indices`, `addresses`, `procedure_addresses` or `statuses`. Add
`include_pseudocode: true` for several chosen bodies, or use
`collection: "pseudocode"` with one row's `index` to page a large body. Batch
limitations aggregate every item, so do not attribute them to one successful row.
Use `record_unknown` to track unresolved questions in the user's investigation
and name the authority or environment still required. Supply supporting and
contradicting evidence IDs. It updates the session registry directly.
Use `update_unknown` with the current revision; reread after a stale revision
instead of retrying blindly. Only qualifying observed evidence can verify a
resolution.
Use comparisons with complete, compatible page sets when claiming equivalence
or absence:
- `compare_artifacts` compares inventory pages by occurrence path, content,
metadata, and graph relations. Name each side as
`{kind: "retained-evidence", evidence_id}` (an `inspect_artifact` result,
its nested inventory or its summary) or
`{kind: "evidence-file", path, evidence_id?}` instead of resending Evidence.
- `compare_functions` compares explicit function Evidence, not fuzzy
whole-binary matches. It rejects an `analyze_function` summary reply; pass
the summary's `parent_evidence_id` as a `retained-evidence` reference or
the complete dossier.
- `compare_bundles` compares canonical bundle membership and unknown history.
- `find_changed_behavior` combines existing comparisons; static differences
remain candidates, not causal proof. Its `comparisons` accept exact references.
- `build_call_path` needs explicit function dossiers from one artifact/provider;
pass complete parent IDs in `functions`. An incomplete frontier makes absence
unknown.
- `correlate_static_and_runtime` uses explicit mappings; matching patterns are
hypotheses, not causality. Both comparison arrays accept exact references.
- `verify_reconstruction` evaluates a finite typed specification. A pass covers
only declared comparable claims, not global equivalence.
Process captures are opt-in behavioral evidence, not a security sandbox. V3
captures cannot be upgraded to V4; rerun the original scenario. Distinguish root
exit from descendant settlement and require freshness when the task needs it.
A timed-out target that must write a final report needs `finalization_ms`: REA
sends SIGTERM to the captured root, keeps observing, and sends SIGKILL once
only if the target has not exited by the end of the interval.
`exit.finalization.signals` lists each attempt with its delivery result; the
observed exit signal stays in `exit.signal`, `exit.code` is null for every
deadline exit, and the code of a finalizer that exited on its own is in
`exit.finalization.exit_code` (null on a signal exit or when no exit was
observed).
Use `inspect_analysis_view` with `view: {kind: "api-surface"}` on retained
`inspect_web_network_capture` HAR Evidence to group observed request lines by
method, origin and path template. Format matches are inferences, not handler
identity or schemas. Group pages cite parent record ordinals; consult record
coverage and exclusions before claiming completeness. No requests are replayed.
When the connected schema advertises `include_query_parameter_names`, select
it in the view for distinct observed names with original record ordinals. Query
values are omitted; the names establish neither required parameters nor a
complete server schema. Keep the original parent Evidence for exact URLs.
Use `inspect_analysis_view` with `view: {kind: "environment-manifest"}` on the
same retained HAR Evidence to project the primary origin's request lines into
named tools with path templates, parameters, observed query names, value-free
body shapes and record pointers for an agent environment. Opt into
`template_rules` only when recorded identifiers need them; every template,
parameter and merged sibling is an inference, no header, cookie or body value is
copied, and the three stated unknowns stay unresolved.
Use `view: {kind: "environment-state"}` with the manifest's `template_rules` to
extract the data those reads observed instead: entity types and identity
fields, entities with recorded values, documents, and per-tool render plans
checked against the recordings. The state comes only from reads before the
first related write; types, identity and list membership are inferences. The
view also gives each state-changing tool a write plan, served only when a later
recorded read confirms its effect and none contradicts it.
`rea environment-serve manifest.json --evidence capture.json` derives that
state at startup, answers read tools from it, serves the write tools whose plan
is served, each applying its effect to that state, and serves `data/reset`,
`data/add` and `data/get` on `/agentenv`; other write tools are not served, and
each plan states why in its `reason`. It replays the capture instead with
`--replay-only` or when the state is unavailable (not derivable, or a catalog
over the discovery budget), and its stopped document's `state` says which
applied.
Use `view: {kind: "environment-coverage"}` with the same rules to see what an
environment built from a capture would serve: read tools answered from the
state or replayed with their reason, and each write tool served or withheld
with a hint about what to record next; it does not apply the serve-time
catalog and state size checks. `rea environment-build <capture.har> <dir>`
inspects, projects and exports in one step and prints the same coverage, or
`null` when the bundle serves replay.
For a PE/ELF export question, `inspect_native_exports` accepts `detail: "summary"`
and retains the complete table. Query `inspect_analysis_view` with
`view: {kind: "native-exports", collection: "exports", names: ["selected_name"],
fields: ["name"], offset: 0, limit: 32}` and the reported parent Evidence ID.
All same-name versions and aliases survive; use `source_index` with the parent ID
for stable identity. `collection: "issues"` pages interpretation failures. Page
coverage describes selected retained rows; `summary.coverage` or `exports.coverage`
still describes the source table, so an empty partial page does not prove absence.
CLI `inspect-native-exports --detail summary --evidence_output PATH` saves the
complete bundle for later `inspect-analysis-view --evidence-bundle PATH` requests.
@@ -0,0 +1,266 @@
# JavaScript and Electron application artifacts
For captured or bundled JavaScript that needs readable source recovery, use
`recover_javascript_sources` with an absolute `path` and absent
`output_directory`. Supply related runtime/shared/lazy files in
`additional_paths` when advertised, then analyze the returned `analysis_input`
directory together. Each input retains its own snapshot, digest and byte-range
provenance; inputs and recovered code are never executed. Recovery requires a
caller-supplied Wakaru on Linux x64; see [the recovery guide](https://github.com/morluto/rea/blob/main/docs/guides/javascript/javascript-recovery.md).
Use `analyze_javascript_application` directly on the operator-supplied ASAR or
extracted tree. When the question concerns known source paths and the connected
schema advertises `source_paths`, select exact inventoried paths such as
`["package.json", "main.js", "preload.js"]`. Include supporting manifests or
source maps explicitly; dependencies are not implicitly analyzed. REA retains
the complete artifact inventory and identity but reads source text and analyzes
only selected files. The result and summary record `source_selection`; files
outside scope and their relationships remain unknown, so partial application
coverage does not mean the selected source failed. Use repeated `--source-path`
on either static application CLI route, or `--source-paths-json` with a JSON
file for selections too long for one command line. Omit the selection when the
question requires complete application analysis; `source_paths: []` is invalid,
not an unfiltered request. `detail: "summary"` reduces delivery
and does not by itself reduce analysis work.
ASAR header/index admission defaults to 16 MiB / 16777216 bytes;
an admission error names `asar.max_header_bytes` and its bounds (at most
256 MiB). Call `update_analysis_settings` with that setting, `action: "set"`
and enough bytes, then retry the same full-archive request. No inspection or
REA restart is required. `REA_ASAR_MAX_HEADER_BYTES` seeds future REA processes.
`source_paths`, `detail`, and `max_heap_mb` do not bypass or raise header admission.
Runtime JavaScript timeout/heap defaults are also editable; explicit per-call
`analysis_timeout_ms` and `max_heap_mb` take precedence.
Exact `#alias` specifiers resolve through the importer's
enclosing package `imports` map to package-local files; nested `node_modules`
packages use their own map. Wildcard, recursive, and external-package import
targets stay unresolved with that limitation. When size is unknown or large and the connected schema advertises
`detail`, request `detail: "summary"`. This returns an `inspect_analysis_view`
Evidence record with a distinct ID and a `normalized_result.parent_evidence_id`
for the complete retained application analysis. Source-map contents are part of
that static analysis. Read findings, coverage, limitations and unknowns, then
follow a specific unanswered question. Older servers without `detail` return
the complete application Evidence inline when it fits.
When the connected schema advertises `max_heap_mb` and `analysis_timeout_ms`,
these select the isolated source analyzer's old-space heap and per-source
deadline. Defaults are 1024 MiB and 300000 ms. They do not change the MCP
process heap or the client's tool deadline. For a host timeout without REA
output, use the [deadline recovery guide](connection-and-recovery.md#distinguish-client-and-server-deadlines)
instead of raising the server budget. A Bun-hosted REA does not enforce the heap choice; on Windows it
instead limits the analyzer's committed memory to that choice plus a 512 MiB
runtime reserve.
Resource-limited analyses preserve completed static facts and
report partial semantic coverage with specific unknown scopes; this does not
mean the source is malformed. Inspect those limits before retrying a source.
When expanded relationships exceed the parent's available memory, inspect the
file's modules item view for retained `static_analysis` and
`semantic_module_analysis`. Their facts remain available even when graph links
are unknown. Cumulative result exhaustion can stop analysis before every file
is examined; the inventory and remaining unknown scopes distinguish that case.
Timeout or cancellation after source analysis begins retains partial Evidence
in the same session. A returned `error.details.partial_observation` reference
can be supplied directly to `inspect_analysis_view`. If the SDK cancels locally, retrieve its recorded ID from
the Evidence bundle before inspecting the partial analysis.
## Inventory route candidates without returning the application graph
When the advertised schema supports `view`, use `detail: "summary"` with
`view: {kind: "javascript-routes", collection: "routes", fields: ["path", "confidence"], limit: 20}`.
The same view works with `analyze_web_bundle` on already selected captured sources.
The complete source Evidence is retained; use its `parent_evidence_id` with
`inspect_analysis_view` for `summary`, `routes`, `occurrences`, `sources`, or
`unknowns`. Filter and batch IDs before requesting more fields, and follow
`coverage.next_offset`. CLI selections require `--evidence_output` to keep the
complete record. Static patterns, inferred deployment bases, heuristic system
classification, and unavailable sources never establish route reachability or
the complete application route denominator. See the
[route inventory guide](https://github.com/morluto/rea/blob/main/docs/javascript-route-inventory.md).
For captured endpoint observations and inference facts, `analyze_web_bundle`
also accepts `view: {kind: "web-bundle", collection: "endpoints", limit: 32}`
with `detail: "summary"`. Read later `endpoints`, `inferences`, or `summary`
pages from the retained parent. Filter before requesting detail; retain
`source_index` together with parent ID and collection, and request `location`
or `basis` when tracing sources. Page exhaustion does not mean producer
completeness: check `completeness`, structured `source_unknowns`, and limitations.
Endpoint candidates remain inferences rather than confirmed network behavior.
## Read a module without repeating analysis
Use the actual returned parent ID, on the same connection, as `source.evidence_id`.
The following IDs are placeholders to replace with returned values. When the
module's node ID is not already known, page identities before selecting an item.
Pages return only node IDs, paths and roles; choose a useful page size and follow
`coverage.next_offset` when another page matters:
```json
{
"source": {
"kind": "retained-evidence",
"evidence_id": "ev_ACTUAL_PARENT_ID"
},
"view": { "kind": "page", "collection": "modules", "offset": 0, "limit": 16 }
}
```
Select the relevant page entry by its exact `node_id` and recorded role:
```json
{
"source": {
"kind": "retained-evidence",
"evidence_id": "ev_ACTUAL_PARENT_ID"
},
"view": {
"kind": "item",
"collection": "modules",
"selector": { "node_id": "jag_node_ACTUAL_NODE_ID" }
}
}
```
Module pages include asset, bundled and source module roles, so one path can
identify several entries. Use `selector.path` only when uniqueness is established;
the returned node ID avoids needing a complete inventory to prove uniqueness. On ambiguity,
select the matching node ID from the page or the error's candidates. Follow
`next_offset` when another page matters. An item exposes that module node's
recorded observations, identity, hashes, exports and source ranges; it does not
include every child syntax observation, related edge or original source file.
An absent item fact is not evidence of absence from the whole application.
For canonical identities and retained source-range counts, use a typed reference
page instead of reading every full item:
```json
{
"source": {
"kind": "retained-evidence",
"evidence_id": "ev_ACTUAL_PARENT_ID"
},
"view": {
"kind": "javascript",
"collection": "modules",
"projection": "references",
"where": { "path": "main.js", "kind": "javascript-module" },
"offset": 0,
"limit": 16
}
}
```
Filters match exact retained strings; omit a filter to include all values, use
`path: null` for unavailable paths, and `path: ""` for a recorded empty path.
Rows preserve canonical `identity` and distinct observation support states in
`basis`. `retained_source_range_count` counts retained source-range observations,
not runtime imports or unique reference sites. Use a row's `node_id` with the item
view above for exact locations and full supporting observations. Follow
`coverage.next_offset`; page exhaustion describes matching retained rows only.
Check `source_coverage` for source selection, partial/unknown graph coverage and
unknown counts before drawing a negative conclusion.
## Trace only the remaining question
Use `trace_application_feature` on the parent application Evidence for one
literal node ID, route, string, API, IPC channel, module, or native export.
Select `incoming` for relationships leading to the seed, `outgoing` for those
leaving it, and `both` when the question needs both. Trace traversal includes
containment relationships, so even a narrow seed can expand many nodes; a
bidirectional trace can reach unrelated sibling modules. A module item or page
is preferable when its observations already answer the question. Do not treat
all reached nodes as participants in the feature. For a broad trace, pass
`view: {"kind": "feature-trace", "collection": "paths"}` (or `summary`) to
retain the complete trace and read terminal paths first; page `nodes` or `edges`
with exact `where.node_ids` through `inspect_analysis_view` as needed.
Reuse retained parent Evidence when the server advertises it, or supply complete
Evidence inline. Include Hopper or Ghidra Evidence only when its artifact digest
matches exactly.
## Recover an oversized display
An oversized response reports `resource_constraint` and
`details.resource: "transport"`. Reuse
`details.reported_limits.evidence_reference` with `inspect_analysis_view` for a
summary, one module, or a stable page of module identities.
`export_evidence_bundle` writes the complete canonical session to a
caller-selected file. These workflows keep the original Evidence and coverage;
a broad follow-up can also exceed framing.
A client can truncate a successful result inside its text envelope without a
REA `resource_constraint`. Treat a truncation notice or incomplete JSON as a
display limitation; do not claim the full record was examined. Reuse the known
parent ID for smaller views, or export complete Evidence to a selected file
while the session remains open. If the parent ID was lost from the preview,
use the prior analysis summary or explicit ledger entries to recover it; do
not invent an ID or rerun analysis just to reproduce the same display.
BrowserWindow preferences, preload and contextBridge surfaces, IPC
registrations, utility processes, and native binding requests are static syntax
observations. Only a unique exact literal IPC channel match supports an inferred
pairing. Dynamic or ambiguous channels remain unresolved. A requested `.node`
member is not a verified native export. Never claim runtime reachability,
registration, defaults, or policy enforcement from static analysis.
For version comparison, analyze each version once, then call
`compare_application_versions`. Accept only its digest, source-map, structural
fingerprint, or non-module semantic matches. Module ordinals and minified names
are not persistent identity. Report added or removed only with complete
opposite-side coverage; otherwise report unknown.
When the question asks how one exact exported callable's returned object shape
changed, analyze each version once and then call
`compare_javascript_export_shapes` with explicit module paths and export names.
Use the returned IDs on the same connection, or complete inline Evidence
records, from both analysis calls. Accept variant
pairing only through the tool's unique exact literal discriminant. Read
`property_inventories` and each change's `presence` before treating `unknown` as
"the name was not observed": inventories list observed property names even when
values stay unknown, with a `source_range` for each paired or unpaired variant.
Array holes and mutation-invalidated slots are not observed properties.
Complete parent coverage reports presence-only
`added`/`removed` independently of unresolved values. Cite the
comparison Evidence and report JSON Pointer changes; dynamic values, ambiguous
variants, and incomplete parent-property coverage stay unknown. This is static
inference, not runtime behavior. When runtime semantics are needed, run
behavioral probes against the relevant application versions and capture them
through the available browser, Electron, or process workflows.
## Reusing application Evidence
When advertised by the connected server, application trace and compare tools
accept complete inline Evidence or
`{"kind":"retained-evidence","evidence_id":"ev_<64 lowercase hex characters>"}`
for their application input (`application`, or `left`/`right`). `inspect_analysis_view`
uses the same retained-reference form in `source`, or portable inline Evidence,
to project a summary, module page, or one module with its recorded observations.
This notation is
a template: replace it with the actual returned ID. Versions before 4.1.0 accept only full
inline Evidence. Use the exact ID
returned by the producer on the same MCP connection. Resolution does not run
analysis or select a provider; findings remain inline. For summary or inspection
replies, select the complete `normalized_result.parent_evidence_id`. Native
observation arrays and runtime reconciliation's `static_layers[].analysis` and
`runtime_observations[]` also accept exact references where advertised. Each
source can independently be inline or retained, preserving identity, coverage
and unknowns. CLI workflows advertising `--evidence-bundle` resolve these inputs
from the explicitly selected bundle. `close_binary` releases the engine while
retaining references and complete Unknown history on the same MCP connection.
`reset_investigation` explicitly clears them; optional `save_path` checkpoints the
complete bundle before clearing. Import that bundle in another connection to
restore its original references. Retention is bounded, in-memory and never silently
evicted; `binary_session.investigation` reports the budget. A missing reference
includes its ID and recovery guidance.
For exact file/native metadata or processing gaps, use
`view: {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"], limit: 32}`.
Rows retain canonical node/observation identity and original Evidence. Paths
match Evidence artifact-path/source-range locations; missing properties remain
omitted and source coverage stays separate from page exhaustion. Batch paths and
select only needed property keys; complete Evidence remains accessible by export.
If names are unknown, opt into `include_available_property_keys: true`; it lists
the original keys per returned observation without unselected values. Keep each
row's node/observation identity paired when composing facts.
These rows select application-graph observations; semantic relationships and
semantic unknown records remain in the complete parent Evidence and trace tools.
@@ -0,0 +1,931 @@
# Native, managed, and packaged artifacts
For release comparisons on one MCP connection, retain `list_procedures` Evidence
from each target and reuse its exact IDs after target switches or `close_binary`.
For a handoff or new process, export the complete bundle and import it there,
including its source closure. `reset_investigation` explicitly clears retained
records; optional `save_path` checkpoints them before clearing.
`match_native_functions` pairs unique exact names and, when supplied,
complete observed body bytes. Reuse optional same-context `list_names`,
`procedure_info`, `read_bytes` and existing `analyze_function` dossiers as
`left_observations`/`right_observations`; an MCP `analyze_function` summary
reply contributes its `parent_evidence_id` as a `retained-evidence` reference,
not the summary itself. Avoid decompiling every function merely to match it. Its summary retains a parent for
`inspect_analysis_view` `function-matches` collections `matches` and `functions`.
Select columns, batch IDs and page individual facts; aliases remain unresolved.
Unpaired observations do not prove additions/removals. Existing matched dossier
pairs include inputs for `compare_functions`; otherwise analyze just chosen pairs.
Aggregate completed comparisons with `find_changed_behavior`, selecting each
comparison's exact `retained-evidence` ID instead of resending full records.
Inline comparison Evidence remains accepted. References belong to the current
server: after handoff, import the original bundle including its source closure.
Do not substitute dossier IDs or summary views. Static differences remain
behavior candidates, with runtime behavior unknown unless separately observed.
For a matching representation refusal, inspect `details.limit` and raise
`native.matching_representation_bytes` with `update_analysis_settings` before
retrying the unchanged MCP request. CLI launch seed:
`REA_NATIVE_MATCHING_REPRESENTATION_BYTES`. The default is 96 MiB; the policy
range is 1 byte to 1 GiB and does not bound process RSS. Keep the complete
inventories: splitting them changes global name/body uniqueness.
## Apple app resources
Use `open_bundle_resources` with the selected app path, then `inspect_plist`,
`decode_interface_builder`, `inspect_asset_catalog`, or `inspect_keyed_archive`.
The resource session reads the app without resolving `CFBundleExecutable` or
starting a deep provider. Preserve `resource_root` provenance, per-resource
hashes where reported, and null executable identity; a directory path is not
an executable digest. Export retained Evidence before closing or switching
roots. Binary analysis snapshots do not apply. Use `open_binary` to switch to
executable analysis, and `close_binary` to release either session kind.
The four matching CLI commands accept the app path directly. For an archive
inside the app, use `inspect-keyed-archive APP_PATH RELATIVE_ARCHIVE_PATH`.
Host requirements for plist and compiled asset/Interface Builder tooling still
apply; a resource-only session does not establish provider availability.
## Desktop installation directories
When an installation directory is supplied and its runtime is unknown, use
`inspect_desktop_application` with its absolute path. Default MCP summary
delivery retains complete Evidence. Read `files`, `omissions`, `candidates`, `components` or
`next_probes` with `inspect_analysis_view` kind `desktop-application`, selecting
exact paths, batched file/omission IDs or families and only needed fields and pages.
Summary `probe_issues` retains counts and bounded previews. Select files with
`has_issues: true` and fields `path`, `read_status`, `issues` to retrieve exact
failures directly; preview text is display data, while `file_id` selects full facts. Preserve source
coverage, read-range identity and unknowns; directory snapshot identity is not
a digest of every file's contents. Component path markers do not establish
runtime presence. For compact CLI output, use
`inspect-desktop-application PATH --detail summary --evidence_output BUNDLE`.
See [desktop installations](https://github.com/morluto/rea/blob/main/docs/desktop-applications.md).
## Go build metadata
Use `inspect_go_binary` for an explicit local ELF, PE or Mach-O (universal
files need `architecture`) when the
question concerns the embedded compiler version, module/dependency versions,
local replacements, build settings, or static Go string literals packed without
NUL terminators. Pass `include_string_literals: true`
(`--include-string-literals`, optional `max_string_literals` /
`--max-string-literals`) to recover bounded inferred word-aligned `(ptr, len)`
UTF-8 string slices from read-only data and page them through `go`
`string_literals` views. No `open_binary`, Go installation or
target execution is required. The CLI equivalent is `inspect-go-binary PATH`.
Preserve artifact SHA-256, original file ranges, framed module bytes and every
unparsed module-text line. A missing record is an unknown, not proof of a
non-Go binary; observed metadata does not authenticate its claims, and static
`(ptr, len)` string recovery excludes compiler-inlined stack/immediate strings
without proving runtime reachability. Function,
source and type recovery remain a separate native-analysis question.
## Model files
Use `inspect_model_artifact` (`inspect-model-artifact PATH`) for local GGUF v2/v3
and safetensors declarations without a model runtime. Header-only mode does not
read weights or establish full-file identity. `hash: "full"` explicitly streams
all bytes. Retained `model-artifact` views page `metadata`, `tensors` and derived
`types`, with exact batch filters and selected fields. Arrays retain typed counts
and eight-element previews; omitted values, unknown types and runtime behavior
stay explicit. See [model files](https://github.com/morluto/rea/blob/main/docs/model-artifacts.md).
## IL2CPP metadata
Use `inspect_il2cpp_metadata` for an explicit local Unity `global-metadata.dat`
(versions 24, 27, 29, 31 and 39) when the question concerns assembly images,
names, member owners, string literals, namespaces or index ranges. No `open_binary`, Unity installation or target
execution is required. The CLI equivalent is `inspect-il2cpp-metadata PATH`.
MCP defaults to counts with complete retained Evidence. Query `sections`,
`images`, `types`, `methods`, `fields`, `properties`, `events`, `parameters` or
`string_literals` through `inspect_analysis_view`; use exact names,
batch indices and selected fields. Row indices are scoped by artifact SHA-256
and collection; page offsets count filtered matches. Parameters support batch
`method_indices` to read one method's parameters directly. Historical reports
without member collections retain unknown counts rather than empty inventories. Preserve
artifact SHA-256 and index widths. Unrecognized magic may mean encryption and
is unsupported, not decrypted. Linking names to `GameAssembly` or
`UnityFramework` code addresses remains a separate native-analysis question.
See [IL2CPP metadata](https://github.com/morluto/rea/blob/main/docs/il2cpp-metadata.md).
## Rust dependency metadata
Use `inspect_rust_binary` for an explicit local ELF, PE, or Mach-O (universal
files need `architecture`) when
the question concerns the cargo-auditable crate list embedded in `.dep-v0`
(Mach-O: `__DATA,.dep-v0`). No `open_binary`, Rust installation, or target
execution is required. The CLI equivalent is `inspect-rust-binary PATH`.
Preserve artifact SHA-256, package order, dependency indices, and the section
file range. A missing section is null metadata, not proof the binary has no
dependencies. An omitted package kind stays null. A rustc commit hash is
inferred from a read-only `/rustc/<40-hex>/` path and does not by itself
identify the compiler. See [Rust binaries](https://github.com/morluto/rea/blob/main/docs/rust-binaries.md).
## PE Authenticode certificate tables
Use `inspect_pe_certificates` for an explicit local PE32/PE32+ image when the
question concerns the Authenticode certificate table, WIN_CERTIFICATE types, or
embedded PKCS#7 signer and X.509 identity fields. No `open_binary`, Windows SDK,
or target execution is required. The CLI equivalent is
`inspect-pe-certificates PATH`. Preserve artifact SHA-256, table file ranges,
payload hashes, original validity-time spellings, and
`subject_key_identifier_hex` when the signer uses that form. A missing
`IMAGE_DIRECTORY_ENTRY_SECURITY` directory is observed absence of that table,
not proof the image is unsigned in every catalog or overlay sense. Signatures,
chains, timestamps, and Authenticode digest coverage remain unobserved. See
[PE Authenticode certificate tables](https://github.com/morluto/rea/blob/main/docs/pe-certificates.md).
## PE import and delay-load tables
Use `inspect_pe_imports` for an explicit local PE32/PE32+ image when the
question concerns imported DLL and function names, ordinals, delay-load
descriptors, or the pefile import hash. No `open_binary`, Windows SDK, or
target execution is required. The CLI equivalent is `inspect-pe-imports PATH`.
Preserve artifact SHA-256, directory file ranges, original DLL spellings, and
ILT/IAT RVAs. A missing `IMAGE_DIRECTORY_ENTRY_IMPORT` directory is observed
absence of that table, not proof the image never imported APIs through another
table or overlay. Bound snapshots, loaded IAT contents, and export-forward
resolution remain unobserved. See
[PE import and delay-load tables](https://github.com/morluto/rea/blob/main/docs/pe-imports.md).
## Windows Shell Link shortcuts
Use `inspect_windows_shortcut` for an explicit local `.lnk` file when the
question concerns the recorded target path, arguments, working directory, icon
location, volume serial, network share names, ExtraData blocks, or Tracker
droid identifiers. No `open_binary`, Windows SDK, COM, or target execution is
required. The CLI equivalent is `inspect-windows-shortcut PATH`. Preserve
artifact SHA-256, FILETIME raw decimals, IDList type bytes, and ExtraData
signatures. Unknown IDList items and ExtraData signatures remain raw with
SHA-256; they are not guessed. Version-1 UUID MAC and timestamp values are
labelled derived. A missing optional section is observed absence of that
structure. See
[Windows Shell Link shortcuts](https://github.com/morluto/rea/blob/main/docs/guides/native/windows-shortcut.md).
## PE TLS callbacks and load configuration
Use `inspect_pe_load_config` for an explicit local PE32/PE32+ image when the
question concerns code that runs before the entry point through TLS callbacks,
or the security cookie, SafeSEH, CFG, EH-continuation and XFG declarations in
the load configuration. No `open_binary`, Windows SDK, or target execution is
required. The CLI equivalent is `inspect-pe-load-config PATH`. Preserve artifact
SHA-256, directory and callback-array file ranges, and callback VAs as
hexadecimal strings. Fields outside the declared `Size` are `null`. Guard-flag
mitigations carry `evidence_kind: "inferred"`; enforcement remains unobserved.
See
[PE TLS callbacks and load configuration](https://github.com/morluto/rea/blob/main/docs/pe-load-config.md).
## Binary hardening
Use `inspect_binary_hardening` for an explicit local ELF, PE, or Mach-O file
when the question concerns PIE or ASLR, NX, RELRO, stack-protector or FORTIFY
signs, control-flow flags, or stripped state. No `open_binary`, pwntools, or
target execution is required. The CLI equivalent is
`inspect-binary-hardening PATH`. Universal Mach-O files return one image per
slice. A missing import is `unknown`, not proof the protection was omitted.
Authenticode and Mach-O code-signature rows report presence, not validity.
`hardened_runtime` stays unknown and names `inspect_signature`. See
[binary hardening](https://github.com/morluto/rea/blob/main/docs/binary-hardening.md).
## ELF note tables
Use `inspect_elf_notes` for an explicit local ELF32/ELF64 image when the
question concerns PT_NOTE/SHT_NOTE owners, GNU build-id, or GNU ABI-tag
fields. No `open_binary` or target execution is required. The CLI equivalent
is `inspect-elf-notes PATH`. Preserve artifact SHA-256, table file ranges,
descriptor hashes, and original owner spellings. A missing table is observed
absence of those declarations, not proof the image never carried notes in
another container. See
[ELF note tables](https://github.com/morluto/rea/blob/main/docs/elf-notes.md).
## ELF and Mach-O initializer functions
Use `inspect_native_initializers` for an explicit local ELF or Mach-O image
when the question is which functions run before `main` or at exit: ELF
`DT_PREINIT_ARRAY`, `DT_INIT`, `DT_INIT_ARRAY`, `DT_FINI`, `DT_FINI_ARRAY` and
legacy `.ctors`/`.dtors`, or Mach-O `__mod_init_func`, `__init_offsets` and
`__mod_term_func`. Pass `architecture` for a universal Mach-O. The CLI
equivalent is `inspect-native-initializers PATH`. PIE slots are resolved
through RELATIVE, RELR and section relocations, and Mach-O slots through
chained fixups. Keep each entry's `resolution`: `unresolved` means the value was
not established, and `unresolved_import` names an external symbol. Page with
`offset` and `limit`. See
[ELF and Mach-O initializer functions](https://github.com/morluto/rea/blob/main/docs/guides/native/native-initializers.md).
## ELF unwind function ranges
Use `inspect_elf_unwind_ranges` for an explicit local ELF32/ELF64 image when
the question concerns function starts and lengths that survive `strip`, which
functions have an LSDA, or which ranges have no exact symbol. No
`open_binary` or target execution is required. The CLI equivalent is
`inspect-elf-unwind-ranges PATH`. Preserve artifact SHA-256, FDE start and
end, containing section, exact symbol name, CIE index, LSDA, and personality.
Page later ranges with `offset` and `limit`. A missing `.eh_frame` is
observed absence of that declaration and of `PT_GNU_EH_FRAME`, not proof the
image has no functions. Header disagreement is a limitation and does not drop
decoded ranges. `.debug_frame` is reported present or absent and is not
decoded. CFA instructions are counted and not evaluated. Indirect EH pointers
are not dereferenced. See
[ELF unwind function ranges](https://github.com/morluto/rea/blob/main/docs/guides/native/elf-unwind-ranges.md).
## Apple IPS crash reports
Use `inspect_apple_ips` for an explicit local Apple `.ips` file when the
question concerns crash metadata, exception and termination facts, threads,
usedImages, lossless registers, or documented crash classes. No `open_binary`,
Xcode, atos, or target execution is required. The CLI equivalent is
`inspect-apple-ips PATH`. Preserve artifact SHA-256, `bug_type` as a string,
uppercase image UUIDs, and integer literals that exceed IEEE-754 range. Only
`bug_type` `"309"` yields a crash body; other DiagnosticReports types return
no crash result. Opened Mach-O and dSYM UUID joins remain unobserved. See
[Apple IPS crash reports](https://github.com/morluto/rea/blob/main/docs/apple-ips.md).
## Linux kernel modules
Use `inspect_kernel_module` for an explicit local ELF32/ELF64 relocatable
object when the question concerns `.modinfo` license, vermagic, alias,
depends, or parm strings, the `__versions` symbol CRCs, or an appended module
signature trailer. No `open_binary`, Linux host, or `modinfo` program is
required. The CLI equivalent is `inspect-kernel-module PATH`. Preserve
artifact SHA-256, key order, duplicate keys, CRC hex, and section file ranges.
A missing section is observed absence when section names are known, not proof
the module never carried that metadata. `.gnu.linkonce.this_module` is reported
as present or absent; `struct module` stays unknown because its layout changes
with the kernel version. `__ksymtab` records are not decoded. The PKCS#7 range
and its SHA-256 are observed; certificate, signer, and digest coverage remain
unvalidated. An absent trailer is not proof the module is unsigned in another
container. gzip, xz, and zstd whole-file containers are unsupported and name
`external_decompression` as the next probe: use an external codec utility to
decompress into a separate file, then call `inspect_kernel_module` on that file. See
[Linux kernel modules](https://github.com/morluto/rea/blob/main/docs/kernel-modules.md).
## Linux kernel build config
Use `inspect_kernel_build_config` for an explicit local kernel image (`vmlinux`
ELF, raw arm64 `Image`, x86 `bzImage`/`zImage`, or `configs.ko`) when the
question is which kernel version it is or how it was configured. No
`open_binary`, Linux host, or `extract-ikconfig` is required. The CLI
equivalent is `inspect-kernel-build-config PATH`; pass `name` for one exact
`CONFIG_*` option and `offset`/`limit` to page the rest. Preserve the banner
version, builder, and compiler, the config SHA-256 and option count, and each
entry's value kind (`y`, `m`, `string`, `number`, `not_set`). A config is
accepted only when `IKCFG_ST` is immediately followed by a valid gzip member
and `IKCFG_ED`, so an ordinary `IKCFG_ST` string is not a config. Offsets with
`space: "inflated_payload"` are positions in an inflated bzImage payload, not
file offsets. An absent config describes only the searched bytes; the kernel
may have been built without `CONFIG_IKCONFIG` or with it as a module. Truncated
payloads are incomplete, not absent. A framed blob that does not decode, has a
bad CRC, or holds non-config text is `malformed`, not absent. xz, lz4, lzma, zstd, bzip2, and lzo
payloads are unsupported and name `external_decompression`: decompress with an
external utility, then call `inspect_kernel_build_config` on the result. See
[Linux kernel build config](https://github.com/morluto/rea/blob/main/docs/guides/native/kernel-build-config.md).
## CPython pyc files
Use `inspect_python_pyc` for an explicit local CPython 3.7–3.13 `.pyc` when the
question concerns the header magic, PEP 552 source hash or timestamp, nested
code-object names, or bytecode identity. No `open_binary`, Python installation,
or target execution is required. The CLI equivalent is `inspect-python-pyc PATH`.
Preserve artifact SHA-256, the 16-byte header fields, nested code names, and
bytecode SHA-256. Bytecode is not decompiled. A decoded header does not prove
the matching `.py` is present. See [CPython pyc inspection](https://github.com/morluto/rea/blob/main/docs/python-pyc.md).
## Erlang BEAM files
Use `inspect_erlang_beam` for an explicit local `FOR1`/`BEAM` file when the
question concerns IFF chunks, atom names, exports, imports, lambdas, or the
Code header. No `open_binary`, Erlang installation, or bytecode execution is
required. The CLI equivalent is `inspect-erlang-beam PATH`. Preserve artifact
SHA-256, chunk file ranges, 1-based atom indices, and original atom spellings.
Missing optional tables stay null. For a few facts, use `detail: "summary"` with
a `beam` view selecting a collection, exact names/indices and fields; read later
pages from `parent_evidence_id` or the complete Evidence bundle. Same-name
exports retain every arity and original atom index. CLI summary delivery requires
`--evidence_output`. Null table counts remain distinct from decoded empty tables. See
[Erlang BEAM files](https://github.com/morluto/rea/blob/main/docs/erlang-beam.md).
## SQLite snapshots
Use `inspect_sqlite_database` with an explicit local database snapshot for its
schema and optional rows from one exact ordinary table. It requires Node.js
24.x >=24.15 or 26+ with the native `DatabaseSync.limits` API. It also consumes the
existing sibling `-wal`; retain both original digests when citing its result.
Default output is a compact inventory; a selected table expands only its own
schema and samples 20 rows. Select `columns` to avoid irrelevant or large cells,
and request `schema_scope: "database"` only for complete schema details.
Choose `row_limit` when a finite sample is useful and keep truncation and
unspecified row order explicit. Virtual tables, shadow tables and views are
schema facts, not row-read targets. REA inspects private copies and leaves
the source files untouched; obtain a coherent quiescent database/WAL snapshot
before inspection. See [SQLite snapshots](https://github.com/morluto/rea/blob/main/docs/guides/data/sqlite-databases.md).
## Recorded Linux crashes
Use `inspect_recorded_crash` with an explicit Linux x86-64 ELF core path to
inspect historical thread registers, signals, raw notes and original file
ranges. It is independent of the active disassembler target. Source-bound
register values and note bytes are observations; missing notes, signal-thread
association and current executable/library identity remain unknown. PIDs are
historical metadata and never authorize live attach or control.
The optional `include_debugger_context` facet adds mapping candidates through
caller-supplied GDB/pwndbg in an owned core-only session. Display names do not
establish current file identity; zero reported flags leave permissions unknown.
Requested unavailable context fails with setup guidance. See
[recorded crashes](https://github.com/morluto/rea/blob/main/docs/guides/native/recorded-crashes.md)
for exact upstream profiles, bounds and verification coverage.
## Offline ELF layout
Use `inspect_binary_layout` with an explicit local ELF path for file-backed
sections, symbols, relocations, linkage names, and static mitigation inferences
without opening a disassembler target. When that complete record is already
retained, call `inspect_analysis_view` for a summary, mitigations or linkage
facet, one section or symbol, or a stable page. Do not re-run the decoder to
read a single object.
## Offline WASM
Use `inspect_wasm_artifact` with caller-supplied WABT for selected local bytes.
For counts, validation and WAT identity, select `detail: "summary"` on the first
MCP call. The complete original Evidence stays retained; use
`normalized_result.parent_evidence_id` for exact exports or text pages. Omitted
or complete detail keeps the existing complete result.
For a completed result, use `inspect_analysis_view` with its reported retained
reference for a summary or `view: {kind: "wasm", facet: "exports", offset: 0, limit: 32}`.
Facets also include sections, imports, candidates, glue, WAT, headers, details
and tool profile. Text offsets count UTF-16 code units; follow `next_offset`.
The complete source remains retained after an oversized MCP reply. No target
execution or network acquisition is implied, and WAT is decoded representation.
## Offline APK signatures
Use `inspect_apk_signature` for a selected local APK/ZIP/AAB. Default MCP summaries
retain complete Evidence. Select `view.collection: signers/digests/lineage`,
with scheme or ID batches, fields and paging; use `inspect_analysis_view` kind
`apk-signature` for retained reads after deletion. IDs retain artifact/scheme
scope and signer links; `fields: []` keeps identity links. Empty pages preserve
partial decoding reasons and unknown authenticity. CLI compact delivery needs
`--evidence_output` to preserve the complete parent.
It reads the APK
Signing Block (v2/v3/v3.1) and v1 `META-INF` signing without executing anything,
and reports each signer's certificate facts (for v1, the certificate the PKCS#7
block's SignerInfo names), the declared v3 rotation lineage,
the declared source-stamp certificate and, for SHA-256/SHA-512 digests, whether
the declared content digest matches the current bytes. That match is not a
signature verification: anyone who changes the bytes can recompute the digest.
`posture` summarises v1-only signing (which cannot tell a v1-only APK from one
whose v2/v3 block was removed), observed signer agreement across schemes,
whether the v1 signer equals the block-scheme signer (`false` is notable),
whether every differing signer appears in a declared (unverified) rotation
lineage, and the strongest scheme.
It never claims authenticity: no signature or lineage is cryptographically
verified.
## Native targets
After `open_binary`, use focused search, procedure, or function tools directly.
Use `binary_overview` when target metadata or inventory context helps answer the
question. Prefer literal search, names, decompilation, callers, callees, and
cross-references. Addresses and recovered pseudocode are analysis observations,
not original source. Provider unavailability and unsupported metadata remain
unknown rather than false.
For large pseudocode, request `procedure_pseudo_code` with `detail: "summary"`
on the first MCP call. It retains the complete observation and returns the
original selection, text availability and length without the body. Use
`normalized_result.parent_evidence_id` with `inspect_analysis_view` and
`view: {kind: "native", facet: "pseudocode", offset: 0, limit: 20000}`;
follow `coverage.next_offset` for later pages. Choose a smaller limit if one
page exceeds the client or transport budget. Offsets and limits count UTF-16
code units, not lines or tokens. Pages reuse the same observation without
repeating decompilation. Null and empty text remain distinct. Omitted or
`complete` detail preserves full inline delivery. Summary delivery requires a
retaining session and does not reduce the work or memory of decompilation.
For a completed `analyze_function` dossier, use `inspect_analysis_view` with
`view: {kind: "native", facet: "value_flow_operations", offset: 0, limit: 32}`
and the exact retained Evidence ID to read selected facts without reanalysis.
The default MCP reply is already a summary of that dossier: its
`normalized_result.parent_evidence_id` is the retained ID to page, and its
null counts mark facets the provider did not record or declared unavailable.
Native facets also include procedure, pseudocode, assembly, basic blocks,
comments, callers/callees, references, unresolved calls, referenced strings and
names, the native API record, and value-flow summaries. Pseudocode offsets and limits count UTF-16
code units; use the returned `next_offset` for continuation. Provider limitations
and unavailable facts remain explicit; the complete parent Evidence is retained.
`inspect_macho` on a large framework returns megabytes of imports and exports.
Select `detail: "summary"` for slice identity, UUID and per-collection counts,
then page rows with `view: {kind: "macho", facet: "exports", offset: 0, limit: 64}`
and `normalized_result.parent_evidence_id`. Facets are entrypoints,
architectures, build_metadata, load_commands, dependencies, imports, exports,
segments and provenance. `source_exhaustive: false` means unreturned symbols
may exist.
To see which functions or Objective-C methods a Mach-O actually calls in one
run, use `observe_native_calls` with explicit breakpoints and a bounded
`duration_ms`/`max_events`. It launches a new process under LLDB, so the target
runs and may change files or use the network. Hardened-runtime targets need the
`get-task-allow` entitlement.
Function breakpoints use exact LLDB runtime names: pass an `inspect_macho` export
or import `runtime_name` (`main`), not its symbol-table `name` (`_main`). REA does
not translate breakpoint names. Set `module` to the image
file name when selecting that image only; otherwise loaded system images also
match. Inspect retained locations and module paths before attributing a call.
Use `binary_session` with no arguments to check the open target, selected
provider, and alignment. Its default summary `result.tool_availability` carries
the complete tool inventory grouped by current status. Expand each group's
`names` and inherit the inventory-level client requirements unless a group
overrides them; availability, reasons, remediation, and modes remain inline.
Provider candidates keep availability and target-support status, code, and
reason. `omitted_fields` names what the summary leaves out; `{"detail":
"complete"}` adds each unavailable candidate's `remediation`, provider
diagnostics, candidate capability sets, and tool surface, `effects`, and
`annotations`. Those arrays name true flags from fixed
vocabularies, so an omitted known flag is false. `tools/list` remains the
canonical source for full tool schemas when target or provider state changes.
Provider capability groups carry provider identity and ordered limitations.
Prepend each set's `shared_limitations` to a group's `limitations` to recover the
operation's full ordered list. Candidate capabilities, returned with complete
detail, describe possible providers. Top-level session capabilities describe the current route, and
`analysis_provider_binding` identifies the selected provider.
## Continue editable Ghidra analysis across processes
For a task spanning agent sessions, check `binary_session` after selecting
Ghidra: the current provider's `capabilities[].shared_limitations` states whether
Program checkpoints are enabled or the session is ephemeral. Successful native
queries distinguish initial analysis from an actually restored packed Program;
do not infer that restoration happened merely because checkpoint mode is enabled.
In ephemeral mode, choose a private absolute directory and set
`REA_GHIDRA_CHECKPOINT_DIR` in the environment starting REA. Restart the MCP server
after changing that launch configuration; no current tool call or runtime
analysis-setting update enables it. Stock ELF/PE/Mach-O imports on Linux/macOS
are supported; see the [checkpoint setup and limitations](https://github.com/morluto/rea/blob/main/docs/getting-started/installation.md#automatically-checkpoint-an-owned-ghidra-program).
With the setting enabled, normal opens restore compatible saved Programs
automatically. Initial completed analysis is saved, and `close_binary` saves
later metadata edits; no separate save/restore tool is needed. Repair a reported
save failure and retry close. Cancellation or process failure can lose edits
after the last successful save; unfinished initial analysis cannot resume.
Keep the same checkpoint configuration for later processes. Evidence/Unknown
history and immutable REA query snapshots have separate persistence workflows.
## Reuse caller-selected Ghidra analysis
When the caller supplies an expanded project, configure `REA_GHIDRA_PROJECT_PATH`
(the `.gpr` marker) and optionally `REA_GHIDRA_PROGRAM_NAME` (one root program),
then restart/reconnect before `open_binary`. CLI uses the same configuration.
REA opens only a fingerprint-bound owned copy with `-process -noanalysis -readOnly`;
original project files are not opened by the engine or saved. Database edits,
import overrides and repair scripts are unavailable in this mode. Treat original
analysis completeness/settings as unknown and prior edits as caller-project state.
The program's recorded import digest must match the target; changed project content
requires reopening for a fresh profile. See the
[setup and preparation guide](https://github.com/morluto/rea/blob/main/docs/getting-started/installation.md#reuse-an-existing-ghidra-project).
## Ghidra startup deadline recovery
`provider_timeout` during the first Ghidra query can cover import, auto-analysis,
bridge startup, and readiness. The default 330,000 ms is configurable through
`REA_GHIDRA_STARTUP_TIMEOUT_MS`; a smaller query still needs the same cold import.
When the error names `ghidra.startup_timeout_ms`, call
`update_analysis_settings` with `action: "set"` and a larger value within the
reported bounds. Resolve retained cleanup before the next startup attempt.
No REA restart is needed; an already accepted startup keeps its original
budget. Follow the error's session-specific restart guidance, and preserve useful Evidence before closing
and reopening the target when remediation requires that lifecycle step.
For a per-function timeout, update `ghidra.decompile_timeout_seconds` and retry
the same function on the existing Program. Explicit `null` removes that
per-function deadline. Inspection is optional when the error supplies the
setting and bounds. Runtime updates disappear when REA exits; environment
changes only seed future REA processes.
The client's tool-call deadline must allow startup plus the requested query;
server settings and progress notifications cannot override it. CLI analysis
avoids the MCP client deadline, but retains the provider deadline. For example:
```sh
REA_GHIDRA_STARTUP_TIMEOUT_MS=1800000 rea analyze /absolute/path/to/app.exe --provider ghidra --json
```
Thirty minutes is an example budget, not a completion guarantee. Keep host CPU
and memory controls in place. Disable specific expensive analyzers only when
their missing coverage is acceptable and retain the resulting limitations.
Exported C and embedded resources remain useful independent evidence, but a
timeout does not establish complete analysis or absence of code. See the
[deadline and client guide](https://github.com/morluto/rea/blob/main/docs/reference/mcp-contracts.md#ghidra-first-query-deadlines-and-recovery).
## Bridge request ceiling
Hopper and Cutter share `REA_BRIDGE_MAX_REQUEST_BYTES`. Unset, both sides use
16777216 bytes. The payload budget excludes the terminating LF. A decimal
integer from 7464 to 536870887 raises or lowers that ceiling. The floor fits
the largest bounded symbol-name command. A tight Cutter status still returns
its session id and up to 64 completion receipts. Its path is included when it
fits, and is unknown when it does not, so an older descriptor path is not
paired with the new generation. The maximum is the encoded payload, one byte under Node's string ceiling so
the framing LF still fits. Cutter measures, signs, and writes a fitting
response in bounded chunks. The bridge searches only the newly read bytes
for the framing LF. The client unescapes each socket chunk into one response
string before parsing it, so this ceiling does not keep the paged wire frame
and another concatenated copy. Invalid values are configuration errors and do not fall back to 16777216. Set the variable in the environment that starts REA, restart REA,
and start Hopper or Cutter from that same environment. An out-of-band bridge
without the setting stays at 16777216 bytes. The client reports that mismatch
during discovery only after the hello proves a live authenticated bridge, and
does not send a status request or the command. A stale descriptor is ignored.
Discovery fails if any such live ceiling differs, even when another live
session matches. A command response discarded for that ceiling stays an
unknown execution with `output_truncated`. A hello
that never sends its authenticated frame does not hold the accept loop.
At most 8 connection threads run; a further accept is closed, and a failure
to start a thread does not stop the listener. `completed_connection` is the
longest prefix of connections that have terminated. Dense scalar replies are
encoded in bounded batches.
The 25000 ms Cutter request deadline and the 256-candidate discovery budget
still have no runtime override.
## Selected native facts and call graphs
For a large `get_call_graph`, or repeated `procedure_address`,
`procedure_callees`, `procedure_callers` and `resolve_containing_procedure`
queries, select `detail: "summary"` when the first 64 recorded observations
suffice. Read further `native-observation` pages from the returned parent Evidence
ID. Graph message IDs index that page's exact-text dictionary; keep level,
source index, failed nodes, and partial/unknown relationship coverage.
Read referenced source context through `native-context` facets `overview`,
`analysis-profile`, `limitations` and `result-limitations`. Reuse inspected
context/profile values only for the same digest; until caveats are read,
interpretation beyond the returned facts remains unknown. Complete Evidence
remains retained and exportable. Small complete replies can cost fewer tokens
than additional context/page calls; count the completed task and discovery.
## Static memory and original file bytes
For Ghidra, `read_bytes` reads initialized analysis memory, including loader
fixups; it does not read a running process. Check `returned_bytes` and `complete`
before decoding a table. An incomplete read is missing coverage, not a zero-filled
tail. Use `list_segments` to inspect the selected block's initialization and address-space
facts, and retain unavailable facts as unknown.
Use `address_to_file_offset` to anchor one observed address to original file
bytes. A mapping for the first address does not establish a contiguous mapping
for an entire table, and loader fixups can make analysis bytes differ from source
bytes. Keep source ranges and analysis-memory observations separate.
A pointer table can refer to writable slots populated by a constructor rather
than directly to immutable resources. An uninitialized block or an observed zero
does not establish the slot's later runtime value. Follow `xrefs` and the relevant
writer's assembly or pseudocode; distinguish recorded assignments from inferred
execution order. Do not replace unreadable bytes with zeros or conclude that a
resource is absent without the necessary initialization evidence.
## DOS MZ with Ghidra
For an admitted DOS MZ target, use `inspect_native_load_image` to check measured
loaded bytes, file mappings, relocations and the entry against the immutable
snapshot. Keep `mismatch` and `unsupported` explicit. Verified import covers the
reported static image, not runtime DOS or PC-98 hardware behavior.
For a large load-image report, select `detail: "summary"` and query its
`parent_evidence_id` with `inspect_analysis_view` kind `native-load-image`.
Choose a collection such as `relocations` or `mappings`, select only needed
fields, and page with offset/limit. Original indices preserve same-address
alternatives. Null collections remain unrecorded or inapplicable; paging does
not upgrade unsupported verification. CLI requires `--evidence_output` for
summary delivery so complete source Evidence remains accessible.
For Ghidra decompilation, `procedure_pseudo_code` also accepts `procedure: "file-offset:0x..."`; CLI `rea decompile <path> --file-offset=0x... --provider ghidra` resolves the source byte offset internally. An unresolved 0x-prefixed numeric procedure selector also falls back to a unique file mapping. Existing symbols and analysis addresses win; multiple mappings fail as ambiguous. Preserve the returned file-offset and address locations and raw selection observations. ELF virtual addresses are distinct from file offsets; never assume an arbitrary offset plus image base is an analysis address.
Complete function body ranges use inclusive ends and can contain gaps. Do not
treat the enclosing span as code.
For packed targets, retain the original and separately derived artifact identities;
decompiling an unpacking stub does not recover the unpacked program.
When reading Objective-C dispatch metadata, distinguish class implementation
methods from protocol requirements: class methods report null requirement
flags, while decoded protocol declarations retain required and optional facts.
Property attributes remain raw name/value tokens; use the property's
`atomicity` field for the decoded `N` marker or the default atomic behavior.
Null class/meta flags, ivar counts, atomicity, or read-only values mean the
selected provider did not establish that fact. Symbol names alone do not prove
that a class is a root class or has no ivars.
## Managed PE/CLI
Start with `inspect_managed_artifact`. REA's canonical managed inspection is
execution-free: do not claim it loaded, reflected, executed, or resolved the
assembly. Keep managed/native boundaries and unavailable reconstruction facts
explicit. A bring-your-own reconstruction oracle is separate from the canonical
parser and must not become an implicit setup dependency.
For identity/classification questions, `inspect_managed_artifact` can explicitly
select `detail: "summary"` on the first MCP call. It retains complete metadata
and returns a linked overview with full classification/evidence, nullable
module/MVID, frameworks, metadata coverage/issues/limitations and returned-row
counts. Default complete requests remain unchanged. Read original rows with
`inspect_analysis_view`, source `normalized_result.parent_evidence_id`, and
`view: {kind: "managed", collection: "attributes", offset: 0, limit: 16}`.
Collections are `references`, `resources` and `attributes`. Paging does not
repeat parsing. Returned-row exhaustion
does not prove complete metadata or absence under partial/unavailable coverage;
preserve build-local tokens and their artifact/MVID scope.
`inspect_managed_members` always returns the assembly-wide `member_refs`, even
for one selected type. For questions about a selected type, select
`detail: "summary"` with `type_names`; it returns the selection (with unmatched
names), coverage and per-collection counts. Page rows with
`view: {kind: "managed-members", collection: "call_edges", offset: 0, limit: 64}`
over `types`, `exported_types`, `fields`, `methods`, `member_refs`, `call_edges`,
`field_accesses` or `user_strings`.
Use exact `names` and `declaring_types` filters before paging supported
collections. For a compact method lookup, request `fields: ["name",
"declaring_type", "signature"]`; omit `body` to avoid anchors. Identity tokens,
original `source_index`, module/MVID and source coverage remain. Check
`unresolved_filter_rows` before interpreting an empty match as absence.
### .NET NativeAOT
NativeAOT binaries contain native machine code, so ordinary CIL decompilers
cannot recover the original method bodies. When managed metadata/CIL is absent,
classify that as a native-analysis route rather than claiming source recovery.
Open the native image with the selected deep provider and inspect functions,
strings, calls, and references; describe decompiler output as pseudocode and
inference.
NativeAOT images can be PE/COFF, ELF, or Mach-O executables and shared
libraries. Use file bytes and loader metadata rather than suffixes to identify
them. For packaged apps, inventory/extract with REA's artifact tools and retain
the exact contained image identity. PDB, ELF debug, or dSYM sidecars are
additional evidence only when the provider accepts and matches them.
REA has a built-in read-only snapshot parser for x64 PE targets with .NET 8 RTR
9.1 metadata. An optional headless Ghidra adapter adds database annotations and
broader metadata recovery on Linux; it requires an explicitly configured
NativeAOT JAR. These are separate routes. The built-in parser also runs on
Windows Ghidra P0; read frozen literals from its report, because
`search_strings` does not include them. See the
[NativeAOT guide](https://github.com/morluto/rea/blob/main/docs/providers/ghidra-nativeaot.md)
for setup, supported layouts, and host restrictions. Preserve binary identity
and distinguish derived metadata from Ghidra database observations. Recovered
types and native pseudocode are not original C# source. Windows Ghidra P0 has
no database mutation authority.
## Packages and extraction
Use `inspect_artifact` for application bundles, archives, ZIP/APK/IPA/MSIX/AppX,
ASAR, or DMG inputs when the artifact graph and findings help answer the
question. It returns the complete artifact graph inline. Cite graph manifest
IDs when using them.
For an IPA or a macOS `.app`, ZIP, or DMG, pass the inventory Evidence to
`project_apple_application_graph`. It reports application roots and nested
bundles by path convention: app extensions, XPC services, frameworks, login
items, system and driver extensions, and plug-ins. It also lists privileged
helpers, launchd plists, symlinks, and each bundle's `info_plist_path` and
executable candidates. Roles are path conventions, not parsed plists; read the
listed plists with `inspect_plist`.
After `open_binary` on a `.app` or Mach-O, use `trace_dylib_resolution` to see
which file each `@rpath`, `@loader_path` and `@executable_path` load reaches for
every executable in the bundle. It also lists missing or weak loads and earlier
`@rpath` candidates that are absent. Narrow large bundles with `roots` or
`architecture`. System paths stay undetermined because the dyld shared cache
provides them.
Use `extract_artifact` when materialized files from a supported format are needed.
It extracts the active target and accepts optional `integrity_policy`; see the [MCP contracts](https://github.com/morluto/rea/blob/main/docs/reference/mcp-contracts.md).
It materializes all regular files into a fresh temporary directory chosen by
REA. Symlinks and encrypted entries are inventory facts, not extractable files.
The result reports the materialized directory; callers do not choose the
destination. No separate permission grant is needed.
For classic iNES cartridges, `open_binary` recognizes the byte signature without
an executable architecture. `inspect_artifact` retains header facts and each
PRG/CHR file unit's offset, length and SHA-256; its nested inventory's
`ines.reconstruction` verifies the actual ordered region bytes against the
whole-file digest. Extraction includes the original header, optional trainer
and uninterpreted trailing bytes. These file units are not mapper/CPU bank
maps. NES 2.0, UNIF, dirty legacy headers and truncated payloads are explicitly
refused. No emulator or 6502 analysis is performed. See the
[iNES guide](https://github.com/morluto/rea/blob/main/docs/ines-containers.md).
Native DMG traversal automatically uses a read-only mount on supported macOS
hosts, with an owned temporary mount directory and cleanup. It needs no approval
flag. Unsupported hosts or mount failures remain explicit limitations; do not
claim child inventory when only root identity is available. DMG extraction is
unavailable through `extract_artifact`. To access DMG files on macOS, create a
read-only mount with your own tools, select files from that mount for analysis,
and detach the mount you created when finished. REA's inspection mount is
temporary, not a retained export of the image's contents.
For headerless DOS COM, explicitly open with `format: "dos-com"` (CLI
`--target-format dos-com`). Inspect the load image before trusting function
analysis: the entry is `0x10100`, file offset zero, with imposed real-mode segment
context. PSP/stack/device state remains unmodeled. See [DOS guide](https://github.com/morluto/rea/blob/main/docs/providers/ghidra-dos.md).
Use `inspect_byte_entropy` to triage high- and low-entropy ranges before extraction.
For other headerless images (embedded firmware without ELF/Mach-O/PE/MZ
headers), open with `format: "raw-binary"`, `architecture` (`x86`, `x86_64`,
`arm`, `arm64`, `6502`, or `m68k`), and `entry_point` (`0x` hex). File offset 0 is mapped at
that address. Hopper and IDA refuse this interpretation; Ghidra uses
`BinaryLoader` with a generic language: big-endian for `m68k`, little-endian
otherwise. Big-endian ARM, Thumb-only, and Cortex-M variants are unsupported.
`6502` selects NMOS 6502; the entry and complete file mapping must fit within
`0x0000..0xffff`. 65C02, 65816, cartridge containers, bank switching, and mapper
semantics are unsupported. `m68k` requires an even entry and decodes the 68040
superset of the 68000 family; Amiga/Atari containers and vector-table entry
discovery are unsupported. Windows Ghidra P0 admits raw images only for `6502`. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/providers/raw-binary.md).
Intel HEX, S-record, UF2 and DfuSe files are containers, not raw images:
`inspect_artifact` lists address regions (UF2 per familyID, DfuSe per element
with its alternate setting), and `extract_artifact` writes each region. Open an
extracted region as `raw-binary` with its start address as `entry_point`. UF2
numbering gaps leave coverage unknown; a DfuSe suffix CRC mismatch follows
`integrity_policy`. See [UF2 and DfuSe](https://github.com/morluto/rea/blob/main/docs/uf2-dfuse.md).
### Function annotations in Ghidra
Fresh-import sessions admit annotations; existing-project reuse is read-only.
Use `annotate_native_function` for one function name and/or entry comments, with at least one explicit change. Review its annotation readback and refreshed dossier inline. Changes are atomic; empty comments clear them, omitted fields preserve them. Later MCP calls observe edits. Normal close saves them when owned Program checkpoint mode is enabled; ephemeral mode discards them. CLI `annotate-native-function` follows the same setting when closing after its updated result. Select the [checkpoint workflow](#continue-editable-ghidra-analysis-across-processes) before edits that must survive a later process. Original executable bytes are unchanged; edits invalidate immutable snapshots. Windows P0 does not admit database mutations. For naming only, `set_address_name` / `set_addresses_names` also work with Ghidra: they name function entries or data addresses (primary labels) and return compact per-address success booleans instead of a dossier. Ghidra rejects batch addresses that request the same name or a name another selected address holds; `raw_result.failures` gives each failure's reason.
A provider switch or different target can retire the active Ghidra database and discard
its edits, even if the new provider fails on a later call. Same-target,
same-profile reopen keeps the active database. Retained annotation Evidence
records what happened; it does not restore the edits.
For `compare_functions`, obtain `analyze_function` Evidence before and after the
edit while the MCP session is open; annotation Evidence is not a comparison
input. Export the needed Evidence before close if comparison will happen later.
Authenticated same-artifact coordinates preserve renamed entries as
`matched / same-entry`, with the canonical `entry_address` inline; name and
exact-text changes remain separate dimensions. This requires compatible
architecture, provider-owned mapping and selected-image commitments. Equal
numeric addresses alone cannot establish correspondence; inspect retained
source Evidence and coverage when the match remains ambiguous.
## Offline PE resource inventory
Use `inspect_pe_resources` with an absolute `path`, or
`rea inspect-pe-resources ./selected.exe --json`, for portable PE32/PE32+
resource inspection without opening a native target. Preserve returned Evidence:
type/name/language IDs and UTF-16 names, original directory/data/payload ranges,
code pages, payload hashes and static icon-group references. Same-language icon
resolution is explicit; missing references and size mismatch remain visible.
Other payloads are opaque, and this operation does not decode images or simulate
Windows resource loading. Consult the budgets and supported tree profile in
[offline binary diagnostics](https://github.com/morluto/rea/blob/main/docs/guides/native/binary-diagnostics.md).
## Offline PE and ELF export tables
Use `inspect_native_exports` with an absolute `path`, or
`rea inspect-native-exports ./libexample.so --json`, to read a PE export
directory or ELF dynamic symbol table on any host without opening a native
target. Ordinal-only, forwarded and hidden-version exports stay distinct;
partial or unsupported coverage means the inventory is incomplete, not that
exports are absent. Use `inspect_macho` for Mach-O. See
[offline binary diagnostics](https://github.com/morluto/rea/blob/main/docs/guides/native/binary-diagnostics.md).
For exact core-module import/export differences, use `compare_wasm_interfaces`
with two complete inspection Evidence records (or current-session retained
references) and explicit scope. Preserve unknown types/duplicate names; static
interface equality is not runtime compatibility. CLI accepts complete inline
Evidence in `rea compare-wasm-interfaces input.json --json`, or exact references
with `--evidence-bundle /absolute/path/evidence.json`. Use the complete parent
ID when the inspection returned a summary.
## Godot PCK
For a standalone PCK, `open_binary` then `inspect_artifact` with
`detail: "summary"` retains the complete static inventory. Use the returned
`parent_evidence_id` with `inspect_analysis_view` inventory occurrences filtered
by exact `logical_paths` or `occurrence_ids`, and join `artifact_id` to nodes
filtered by `artifact_ids`. Select only needed fields; identity links remain.
Prefer `inspect-artifact --detail summary --evidence_output PATH` for CLI tasks;
the same CLI discovers embedded packs via their EOF trailer without opening a
native analysis provider. Normal native opening remains unchanged.
Distinguish declared MD5 from observed SHA-256 and graph/page completeness from
`source_coverage`. Encrypted directories and sparse bundles have unknown entry
coverage; encrypted members have unknown content identity. An empty filtered
page cannot prove absence in unknown coverage. Retained views are historical,
not fresh filesystem checks. No decryption, Godot resource decoding, GDScript
decompilation or target execution is provided. See
[Godot PCK](https://github.com/morluto/rea/blob/main/docs/godot-pck.md).
UnityFS AssetBundle containers (`.bundle`, `.ab`, YooAsset packages and other
byte-identified files) list `CAB-*` SerializedFiles and `.resS` members with
header and node facts; none/LZ4/LZ4HC member blocks stream on demand. Bundles
declaring asset-bundle encryption keep header facts with unknown directory
coverage; LZMA member bytes retain named occurrences with unavailable hashes.
Virtual member offsets are not physical carrier locations. Format 6–8 readers
cache one decoded block and support retained filtered/paged views. No
decryption or SerializedFile object decoding is provided. See
[UnityFS](https://github.com/morluto/rea/blob/main/docs/unityfs.md).
WeChat mini-program packages (`.wxapkg` files with `0xBE`/`0xED` marks) list
members with declared names and absolute carrier offsets; member bytes are
stored verbatim. `V1MMWX` PC-client packages report `encrypted` directory
status with unknown coverage and are refused on extraction; no decryption is
attempted. See
[wxapkg](https://github.com/morluto/rea/blob/main/docs/wxapkg.md).
U-Boot legacy uImages use the same inspection/extraction workflows. Prefer a
retained summary and selected inventory views for header facts or component
IDs. Header addresses/CPU/OS are declarations; payloads remain as stored.
Multi-file components retain their aligned physical ranges; the full payload
is also materialized. Unknown name text retains its original 32 bytes. See
[U-Boot uImage](https://github.com/morluto/rea/blob/main/docs/uimage.md).
## ESP images
For an ESP32-family app or bootloader image, `open_binary` then
`inspect_artifact` with `detail: "summary"`. `root_format_metadata` holds the
observed `header` and `descriptor` (its `kind` is the app or bootloader role)
and the derived `chip`, `checksum`, `appended_sha256` and
`undescribed_trailing` ranges. Segment rows (`segment-NN.bin`) carry
`load_addr`, `classification` (`mapped`, `loaded`, `not-loaded` or `unknown`,
per ESP-IDF `should_map`/`should_load`; RAM ranges are not checked) and
`source_location`; select them with `inspect_analysis_view` and
`logical_paths`. Signature blocks are `unverified`; `public_key_sha256`
matches eFuse key digests.
Unknown `source_coverage` means truncation or undescribed trailing bytes, such
as the flash segments of a `--ram-only-header` image; do not infer absence
from it. Erased flash is described. Integrity mismatches are facts, not
refusals. ESP8266 images are refused, and flash-encrypted images are not
recognized. Segments are not disassembled, and RISC-V or Xtensa segments
cannot be opened as raw-binary. See
[ESP images](https://github.com/morluto/rea/blob/main/docs/esp-image.md).
## ESP flash images
A whole-flash dump opens as `esp-flash-image`. `root_format_metadata` holds
`partition_table` (its `method`: `default`, `caller` or a derived
`scan`), `bootloader` and `apps` ESP image facts, the `otadata` records,
`partitions_outside_dump` and `boot_selection`. Every table has a
`boot_selection`: its `basis` names the bootloader's branch (`no-otadata`
without otadata, `none` when the bootloader rejects the table, `unknown`
when a record is missing), and it lists its `assumptions`;
`running_partition` is always `unknown`. Each image names its entry
`path`. With `detail: "summary"` the root kind is `esp-flash-image-summary`
and each image is a compact `summary` (chip, entry point, segment count,
classified load ranges, signature block count, descriptor identity,
integrity); complete detail keeps every segment. Partition rows
(`partitions/<label>.bin`) carry the declared `type`, `subtype`, names,
`flash_address`, `size` and flags; `encrypted` is a storage flag, not
proof of ciphertext. `written_offset`/`written_end` bound the bytes that
are not erased: an app slot whose facts say `erased` can still hold an older
image, and a limitation says so. For a dump read from another flash address, call
`open_binary` with `format: "esp-flash-image"` and `flash_offset`; an
`esp-image` whose `flash_dump` is set names that offset in a next probe; a
relocated table can be named with `partition_table_offset`. No table found
means unknown, often flash encryption. Extraction writes `nvs_keys` as
ordinary data. See
[ESP flash images](https://github.com/morluto/rea/blob/main/docs/esp-flash-image.md).
## Bun standalone executables
A `bun build --compile` executable stores its module graph in Mach-O
`__BUN,__bun`, a PE or ELF `.bun` section, or an appended trailer.
`open_binary` then `inspect_artifact` reports root format `bun-standalone` with
the carrier, structural layout, module count, entry point, raw flags and
compile exec-argv; each module keeps its `/$bunfs/...` source name, loader,
encoding, sizes and SHA-256, with sourcemap, bytecode and module-info regions as
separate children. Use summary root metadata `entry_point_logical_path` as the
exact inventory `logical_paths` selector, then read the selected content node by
its artifact ID; the reader owns virtual-name normalization and indexed fallback. `extract-artifact` writes stored contents through the safe
output tree; route extracted JavaScript to `analyze_javascript_application`.
Layout labels describe record shapes, not a detected compiler version; unknown
or ambiguous layouts are unsupported. Bytecode is retained, not decoded, and the
executable is never run. See
[Bun standalone executables](https://github.com/morluto/rea/blob/main/docs/bun-standalone.md).
## Deno compile executables
A `deno compile` executable stores its application in a Mach-O `__SUI,d3n0l4nd`
section, an Intel Mach-O `<~sui-data~>` trailer, an ELF `SUI` note of type
`0x53554901`, or a PE `RT_RCDATA` resource named `D3N0L4ND`.
`open_binary` then `inspect_artifact` reports root format `deno-standalone`
with the carrier, entry point, recorded options, and one occurrence per virtual
file, including size and SHA-256. Transpiled output, CJS export analysis, and
source maps are separate occurrences with the file's `index` and their own
`role`. They normally use a `.deno-*` suffix on the file path and move to an
indexed path on a collision, so use the reported `logical_path`. Symlink targets
stay recorded path parts. A virtual filesystem that cannot be listed leaves
coverage unknown.
Environment-file values and CA certificate bytes are retained in local Evidence
with their counts and byte lengths. The npm snapshot length is reported and not
decoded. `extract-artifact` writes
stored files through the safe output tree; route extracted JavaScript to
`analyze_javascript_application`. Other layouts are unsupported, and the
executable is never run. See
[Deno compile executables](https://github.com/morluto/rea/blob/main/docs/deno-standalone.md).
@@ -0,0 +1,140 @@
# Runtime observation
## Native runtime questions
Start from the requested observation. State the selected target, input, expected
result, failure signal and stopping condition before a run. Reuse static Evidence
to choose a function or hypothesis; `trace_native_values` follows recovered
static value dependencies and does not measure runtime values. Prefer static
inspection or existing capture comparison when it already answers the question.
| Question | Existing workflow | Host and effects |
| -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Which selected native entry is reached, with which integer argument registers? | `observe_native_calls` on the active Mach-O, with exact function/Objective-C breakpoints and a duration/event bound | macOS with LLDB and required debugger access. Launches a new owned process, stops at entries, records and continues; target execution may write files, show UI or use the network. It never attaches to an existing process. Inspect termination and partial coverage after the run. |
| What does caller-authored instrumentation observe? | `list_frida_devices` / `list_frida_processes` when discovery is needed; `start_frida_session`, `load_frida_script`, then status/unload/close | Requires compatible installed Frida bindings and selected device access; the real REA verifier covers Linux. Attach selects an existing PID; spawn selects a program and starts it paused until `resume_frida_session`. Scripts execute in the target and can change memory, files, network activity and behavior. This is not passive inspection. |
| Which debugger state or controlled execution explains the behavior? | `start_gdb_session` and `gdb_console`, or `start_rizin_debug_session` and `rizin_debug_command` | Persistent debugger workflows are Linux-only. GDB starts a debugger; console commands choose/load/attach/run targets. Rizin debug start launches the selected target and requires a durable `inferior_tty`. Both consoles are unrestricted and can change target memory/registers, run shell commands, write files and use the network. |
| How do terminal output, process lifetime and selected files differ between runs? | `capture_process_scenario`, then `compare_process_captures` with compatible recorded inputs | New captures require Linux or macOS and a working native PTY backend; macOS also needs `xcrun`/Swift for ownership inspection. Capture launches an owned command with ordinary user permissions. Observation paths select snapshots, not a filesystem sandbox. Comparing retained captures does not execute targets and is also available on Windows. |
| What does an existing desktop window show, or what changes after a declared UI action? | `observe_native_ui`; use `capture_native_ui_scenario` only for requested actions | macOS 14+ with required Screen Recording/Accessibility access. Select an existing PID/window. Observation does not launch or own the app; scenario actions can change its data or trigger network activity, and leave its state as-is. |
Close each persistent session with its matching tool. `close_frida_session`
unloads scripts and detaches; it does not kill the target. A still-paused spawn
is resumed only after instrumentation cleanup is confirmed. GDB and Rizin close
can detach a live inferior and let it continue only after verifying its durable
output terminal. When disposition or cleanup is unresolved, preserve the owned
session and reported recovery; do not assume that closing the debugger terminated
the target. Process capture and LLDB call observation instead own their launched
processes and report their termination/cleanup outcome. Cancellation is not proof
that cleanup completed.
Unrestricted GDB/Rizin commands and relevant start/close operations retire an
active binary-analysis session in the same MCP server. Close an active Ghidra
session explicitly first; after target effects settle, reopen the artifact before
new static analysis. A Cutter bridge command is likewise unrestricted and may
affect its GUI/target; it is not a passive native observation substitute.
To test a static hypothesis across two builds, retain compatible static
comparison Evidence and `compare_process_captures` Evidence. Supply explicit
finding/dimension mappings to `correlate_static_and_runtime`. It does not ingest
arbitrary Frida, LLDB or debugger traces, auto-match names, or prove causality
from cochange. Preserve those traces as their own Evidence and leave unobserved
behavior unknown. Record truncation, unmatched breakpoints, changed target
identity and cleanup uncertainty as limitations, rather than treating no retained
event as proof that the behavior never occurs.
For same-session MCP comparisons, pass each capture inline or reuse it as
`{"kind":"retained-evidence","evidence_id":"ev_..."}`. Default comparison
keeps capture-local timing as provenance without treating scheduler or observer
latency as a behavior change; use an exact `trace_spec` event when timing is part
of the analyst question.
Detailed contracts and prerequisites: [Frida](https://github.com/morluto/rea/blob/main/docs/guides/runtime/frida-instrumentation.md),
[GDB/Rizin/Cutter](https://github.com/morluto/rea/blob/main/docs/reverse-engineering-tools.md),
[process capture](https://github.com/morluto/rea/blob/main/docs/guides/runtime/process-capture.md),
and [native calls/UI](https://github.com/morluto/rea/blob/main/docs/guides/native/native-investigation.md#native-desktop-observation).
## Browser and Electron
Supply the existing browser or Electron instance's literal-port loopback CDP
endpoint in the request. No separate permission configuration or per-call
approval flag is needed. Browser discovery lists eligible HTTP(S) pages;
`allowed_origins` optionally filters them. An individual capture defaults to the
selected page's current origin. Electron discovery lists eligible local
`file://` targets without a configured file-root allowlist.
Start passive browser work with `list_browser_targets`; start Electron runtime work with
`list_electron_targets`. Before an electron-main Inspector attempt, check
packaged fuses with `inspect_electron_fuses`. Inspect the target selected for
the user's task. Observation with these tools is passive: never claim REA
clicked, navigated, evaluated page JavaScript, invoked IPC, or captured prior
activity. REA does not contain the page's network.
Passive captures omit raw header collections, credential header values, storage
values, and raw WebSocket/JSON bodies. Selected response metadata, including
content encoding, policy values, and Link URLs, is retained. Local URLs retain ordinary query
values and fragments; userinfo credentials are removed. Browser accessibility
text, console primitives, value-free shapes, script sources, source maps, and
storage key names have request-specific capture options. Electron script
sources are also optional. Use `capture_web_screenshot` when pixels are needed.
Treat scope filtering, truncation, and attach-window coverage as limitations.
Page-declared WebMCP tools are untrusted inventory and are never invoked by REA.
## Who performs the action
- The user performs it: `list_browser_targets`, then `inspect_web_page` or
`observe_web_session` on the selected target.
- The agent must perform it: one `capture_browser_scenario` with declared
actions. Use connect mode with the loopback CDP endpoint and `target_id` for
an existing browser, or launch mode with a caller-selected executable for a
temporary profile. Connect mode first navigates the selected page to
`start_url`, which can clear unsaved form values and other temporary state.
Recreate the state needed for the investigation in the declared actions.
Connect mode leaves the external browser open; launch mode owns a temporary
browser and profile. Check `browser.cleanup` for the outcome.
Request only the DOM, accessibility, or network details needed for the question.
The default final capture contains the URL; step outcomes are reported separately.
For multi-step flows with trees or events, select `detail: "summary"` and page
exact steps, transactions, text slices or events with `inspect_analysis_view` kind `browser-scenario`.
Add a wait action for the expected page state before capturing it. Report that
REA performed the declared actions.
Scenario network records have no request initiator stacks. For that evidence,
use passive capture while the user performs the action. Listener source locations
identify candidate handlers; they do not establish which script sent a particular
request. Avoid overlapping passive capture and scenario actions on the same target.
Focused follow-up tools:
- `observe_web_execution`: execution coverage during a window; it resets
counters and temporarily disables optimization.
- `inspect_web_event_listeners`: source locations for listeners registered
directly on the first CSS match, without dispatching an event.
- `compare_web_captures`: two normalized passive captures or recorded scenarios.
- `discover_webmcp_tools`: inspect page-declared WebMCP inventory; never invoke
the discovered tools.
Use `reconcile_javascript_runtime` only after static application Evidence and
passive browser/Electron Evidence exist. Prefer captured-source digest identity;
report path/digest disagreement as a mismatch and preserve ambiguity. Explicit
path mappings are inference inputs and never broaden filesystem or CDP authority.
A bundle observed at runtime does not prove every contained module executed.
## Fiddler Everywhere traffic debugging
For large saved captures, request `detail: "summary"`, then page retained Evidence
with `inspect_analysis_view` and `view: {kind: "capture", detail: "preview", offset: 0, limit: 20}`.
Select complete record pages only for needed headers, metadata or payloads; views
read the historical parent without repeating capture decoding.
For selected protobuf/gRPC bodies, keep raw bytes as the source and use
`inspect_analysis_view` kind `protobuf`, collection `bodies`, `frames` or
`fields`. Batch exact record ordinals and field numbers/IDs, and select
`schema_value` or `text` only when needed. Supply a local descriptor set and
message type for schema interpretation; compressed payloads remain unknown.
Inspect native `.saz` snapshots with `inspect_web_network_capture` and
`format: "saz"`; encrypted archives are unsupported. For live
traffic analysis, connect Everywhere's separate MCP server and use its
advertised session tools with an explicit `sessionsSource` (`LiveTraffic` or
`AgentCalls`). Preserve existing sessions and filters when inspecting a
recorded run. See the [capture and MCP guide](https://github.com/morluto/rea/blob/main/docs/guides/web/web-network-captures.md)
for the workflow and Telerik's public setup and traffic-debugging skills.
+3 -14
View File
@@ -25,7 +25,7 @@ jobs:
node-version-file: .nvmrc
- name: Validate the SDK pin against the provider's verified release
run: node scripts/verify-ghidra-bridge-sources.mjs --check-sdk-pin >> "$GITHUB_ENV"
# The bridge probe imports only source-owned ARM, Thumb and MIPS bytes and never
# The bridge probe imports only source-owned ARM/Thumb bytes and never
# executes target code. All SDK/project state stays in runner storage.
- name: Acquire checksum-verified Ghidra SDK in isolated runner storage
shell: bash
@@ -46,7 +46,7 @@ jobs:
java_home_variable="JAVA_HOME_${GHIDRA_JAVA_MAJOR}_X64"
test -n "${!java_home_variable}"
JAVA_HOME="${!java_home_variable}" npm run verify:ghidra:bridge-sources -- --verified-sdk
- name: Inspect real ARM, Thumb and MIPS instruction context through the bridge
- name: Inspect real ARM and Thumb instruction context through the bridge
shell: bash
run: |
java_home_variable="JAVA_HOME_${GHIDRA_JAVA_MAJOR}_X64"
@@ -58,21 +58,10 @@ jobs:
-import "${probe_root}/arm-thumb.bin" -loader BinaryLoader -processor ARM:LE:32:v7 \
-noanalysis -readOnly -deleteProject \
-scriptPath "${GITHUB_WORKSPACE}/tests/conformance/ghidra;${GITHUB_WORKSPACE}/bridge/ghidra" \
-postScript ReaInstructionContextProbe.java "${GITHUB_WORKSPACE}/bridge/ghidra" \
-postScript ReaArmContextProbe.java "${GITHUB_WORKSPACE}/bridge/ghidra" \
> "${probe_root}/output.log" 2>&1 || { cat "${probe_root}/output.log"; exit 1; }
cat "${probe_root}/output.log"
grep -F 'REA_ARM_CONTEXT_OK arm=0 thumb=1 unavailable=null (injected provider value)' "${probe_root}/output.log"
mkdir -p "${probe_root}/mips-project"
# MIPS32 addiu v0,a0,3; MIPS16 li v0,90. These bytes are never executed.
printf '\003\000\202\044\132\152\000\000' > "${probe_root}/mips.bin"
timeout 180s "${GHIDRA_INSTALL_DIR}/support/analyzeHeadless" "${probe_root}/mips-project" MipsContext \
-import "${probe_root}/mips.bin" -loader BinaryLoader -processor MIPS:LE:32:default \
-noanalysis -readOnly -deleteProject \
-scriptPath "${GITHUB_WORKSPACE}/tests/conformance/ghidra;${GITHUB_WORKSPACE}/bridge/ghidra" \
-postScript ReaInstructionContextProbe.java "${GITHUB_WORKSPACE}/bridge/ghidra" mips \
> "${probe_root}/mips-output.log" 2>&1 || { cat "${probe_root}/mips-output.log"; exit 1; }
cat "${probe_root}/mips-output.log"
grep -F 'REA_MIPS_CONTEXT_OK standard=0 mips16=1' "${probe_root}/mips-output.log"
# Each probe imports source-owned bytes at 0x1000, away from the Cortex-M
# vector symbols that ARMCortex.pspec defines at 0, and decodes them from
# the language's default context, so M-profile Thumb state must hold at
+2
View File
@@ -12,6 +12,8 @@
"package-lock.json",
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
".cursor-plugin/plugin.json",
"mcp.json",
"_reference/**",
"third_party/**",
".codex-plugin/mcp.json"
+3 -2
View File
@@ -92,8 +92,9 @@ project. Installation and recovery details are in the setup guide below.
Setup supports Claude Code, Codex, Cursor, Gemini CLI, Grok Build and
[other agents](docs/getting-started/installation.md#supported-agents). See
[installation and setup](docs/getting-started/installation.md) for provider configuration and
manual MCP registration and Claude Code/Codex plugin installation.
[installation and setup](docs/getting-started/installation.md) for provider configuration,
manual MCP registration, Claude Code/Codex plugins, and the Cursor Marketplace
plugin.
MCP uses compact input schemas by default to reduce context overhead. Set
`REA_MCP_INPUT_SCHEMA_PROFILE=full` in your MCP registration when you need the
+4 -2
View File
@@ -350,8 +350,10 @@ tag remains in main's ancestry. Keep the released tag unchanged.
The synchronization must include `.release-please-manifest.json`,
`package.json`, both root versions in `package-lock.json`, `CHANGELOG.md`,
`server.json`, both plugin manifests (`.claude-plugin/plugin.json` and
`.codex-plugin/plugin.json`), the Codex server declaration (`.codex-plugin/mcp.json`), both
`server.json`, the Claude Code and Codex plugin manifests
(`.claude-plugin/plugin.json` and `.codex-plugin/plugin.json`), the Codex
server declaration (`.codex-plugin/mcp.json`), the Cursor Marketplace manifest
(`.cursor-plugin/plugin.json`) and server pin (`mcp.json`), both
marketplaces (`.claude-plugin/marketplace.json` and
`.agents/plugins/marketplace.json`), and the versioned documentation examples. Run
`npm run docs:generate`, `npm run docs:check`, and the release configuration
+76 -11
View File
@@ -3,15 +3,16 @@
Run REA, connect your selected agents, and check the tools needed for your task.
Setup previews configuration changes and asks for approval before applying them.
| Task | Section |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| Connect an agent | [Start setup](#start-setup), then [review changes](#review-setup-changes) |
| Install a Claude Code/Codex plugin | [Agent plugins and marketplaces](#agent-plugins-and-marketplaces) |
| Run unreleased code | [Run REA from a checkout](#run-rea-from-a-checkout) |
| Diagnose one task | [Scoped readiness checks](#check-readiness-for-your-task) |
| Configure an analysis engine | [Hopper](#hopper), [Ghidra](#ghidra), the [IDA guide](../providers/ida-provider.md), or [Kuna](../kuna-provider.md) |
| Update or remove REA | [Maintenance](#diagnose-update-and-remove) |
| Resolve a client-specific problem | [Client configuration](client-configuration.md) |
| Task | Section |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| Connect an agent | [Start setup](#start-setup), then [review changes](#review-setup-changes) |
| Install a Claude Code/Codex plugin | [Agent plugins and marketplaces](#agent-plugins-and-marketplaces) |
| Install the Cursor Marketplace plugin | [Cursor Marketplace](#cursor-marketplace) |
| Run unreleased code | [Run REA from a checkout](#run-rea-from-a-checkout) |
| Diagnose one task | [Scoped readiness checks](#check-readiness-for-your-task) |
| Configure an analysis engine | [Hopper](#hopper), [Ghidra](#ghidra), the [IDA guide](../providers/ida-provider.md), or [Kuna](../kuna-provider.md) |
| Update or remove REA | [Maintenance](#diagnose-update-and-remove) |
| Resolve a client-specific problem | [Client configuration](client-configuration.md) |
## Start setup
@@ -1843,7 +1844,10 @@ The repository's single-plugin marketplaces are declared in
`.claude-plugin/marketplace.json` and `.agents/plugins/marketplace.json`.
Both select an exact `rea-agents` npm version instead of mutable source-checkout
contents. Release Please updates their selectors alongside the plugin manifests.
No plugin-only publication or generated skill files committed to Git are needed.
Claude Code and Codex do not need plugin-only publication or generated skill
files committed to Git. The Cursor Marketplace plugin is the exception in
[Cursor Marketplace](#cursor-marketplace): its skill copy is committed because
that install is a Git checkout.
The qualified release archive contains a runtime-only manifest and
`npm-shrinkwrap.json` plus bundled runtime dependencies, including PTY packages
for each supported OS and CPU; development dependencies and development-only overrides
@@ -1884,7 +1888,8 @@ only the registrations and skill locations that `rea setup` manages, so for a
plugin-only install `rea doctor --client <id>` and `rea doctor --skill` report
`missing` and recommend `rea setup`. Do not follow that remediation. Check the
plugin with the client instead (`claude plugin list` and `/mcp` in Claude
Code, `codex plugin list` in Codex).
Code, `codex plugin list` in Codex, Cursor's plugin settings for a Marketplace
install).
For analysis engines, ask the connected server: have the agent call
`binary_session` and read `result.tool_availability`, which reports what the
@@ -1907,6 +1912,66 @@ That report describes the terminal's environment, not the plugin server's.
The server inherits the client's environment, so a setting such as
`GHIDRA_INSTALL_DIR` that exists only in your terminal does not reach it.
### Cursor Marketplace
Cursor's Marketplace installs this public repository as one plugin. The
install includes both the `reverse-engineer-anything` skill and the `rea` MCP
server. REA does not publish an MCP-only "Add to Cursor" install link, because
that link would register the server without the skill.
The manifest is `.cursor-plugin/plugin.json`. Cursor discovers `mcp.json` at
the repository root and the skill directory named by `skills`. The committed
skill is `.cursor-plugin/skills/reverse-engineer-anything`, a copy of the
authored skill at `.agents/skills/reverse-engineer-anything`.
`node scripts/generate-cursor-plugin-skill.mjs --check` fails when that copy
drifts, including in `npm run docs:check` and package prepack. Marketplace
installs are a Git checkout and do not contain `dist/`. The server entry
therefore runs the published package instead of a local build:
<!-- x-release-please-start-version -->
```json
{
"mcpServers": {
"rea": {
"command": "npx",
"args": ["-y", "[email protected]", "mcp"]
}
}
}
```
<!-- x-release-please-end -->
Release Please updates that pin with `.cursor-plugin/plugin.json` and
`package.json`. The skill text can describe capabilities newer than the pinned
server until the next release; follow the connected server's tool list. The
same pin is what `rea doctor --provider <id>` should use when you inspect an
engine from a terminal, as shown above.
A Marketplace install gives you:
- the `reverse-engineer-anything` skill and its references
- an `rea` MCP server launched with `npx -y rea-agents@<pinned version> mcp`
(Cursor shows it as `plugin-rea-rea`)
- the shared plugin logo at `assets/rea-plugin.png`
It does not install analysis engines, put `rea` on `PATH`, or write
`~/.cursor/mcp.json`. `rea setup --client cursor` remains the direct
registration path and still writes that file plus the setup-owned skill. Use
the Marketplace plugin or that registration, not both, so Cursor does not
start two `rea` servers.
Node.js and npm must be available to Cursor. The plugin does not install them.
Restart Cursor after the plugin is listed. If tools stay missing, read the
plugin server's launch error. `rea doctor --client cursor` does not see
Marketplace state and can report `missing`; do not run setup to repair a
plugin-only install.
Publishing the listing is a separate maintainer step at
[cursor.com/marketplace/publish](https://cursor.com/marketplace/publish).
This repository change does not submit the plugin.
## MCP Registry
REA is published in the official MCP Registry as `io.github.morluto/rea`. Registry
-1
View File
@@ -26,7 +26,6 @@ for new work, roadmap granularity and producer-owned contracts.
- [Native call source coordinates](native/call-source-locations/spec.md) ([plan](native/call-source-locations/plan.md), [tasks](native/call-source-locations/tasks.md), [verification](native/call-source-locations/verification.md))
- [Go static string-header literal recovery](native/go-string-literals/spec.md) ([plan](native/go-string-literals/plan.md), [tasks](native/go-string-literals/tasks.md))
- [Windows Shell Link shortcut inspection](native/windows-shortcut/spec.md) ([plan](native/windows-shortcut/plan.md), [tasks](native/windows-shortcut/tasks.md))
- [NTFS $MFT and USN Journal inspection](native/ntfs-mft/spec.md) ([plan](native/ntfs-mft/plan.md), [tasks](native/ntfs-mft/tasks.md))
- [Solidity terminal metadata inspection](solidity/cbor-metadata/spec.md) ([plan](solidity/cbor-metadata/plan.md), [tasks](solidity/cbor-metadata/tasks.md), [verification](solidity/cbor-metadata/verification.md))
- [Inspect a V8 code cache container](v8/code-cache-inspection/spec.md) ([plan](v8/code-cache-inspection/plan.md), [tasks](v8/code-cache-inspection/tasks.md))
- [Deno compile executable inventory and extraction](artifacts/deno-standalone/spec.md) ([plan](artifacts/deno-standalone/plan.md), [tasks](artifacts/deno-standalone/tasks.md))
-39
View File
@@ -1,39 +0,0 @@
# Implementation plan
## Boundary and contract
Follow the existing artifact readers. `src/domain/windows/ntfsMft.ts` owns FILE
record bytes and coverage; `src/domain/artifactFormatMetadata.ts` owns the
`ntfs-mft` result schema. `NtfsMftArtifactReader` reads the admitted file
through `ArtifactFileSource` and exposes its facts through `formatMetadata()`.
A record stream is its own content, so the root occurrence carries every fact
and no members are enumerated.
Recognition lives in `hasMftFileRecordSignature` and is applied in
`classifyArtifactBytes`, next to the other unambiguous magic probes.
## Incremental work and validation
1. Define the first-step contract, limits and observations; add parser fixtures.
2. Implement the FILE record header and update-sequence fixup parse with
explicit limitations.
3. Add the reader, format descriptor and byte classification.
4. Decode attribute records, then `$FILE_NAME` attributes so a record's parent
reference, names and timestamps become explicit; keep hard links separate.
5. Parse `$UsnJrnl:$J` records and correlate them to records by full file
reference, including sequence numbers, reporting unresolved references.
6. Document the workflow in a native Windows artifact guide.
## Effects and compatibility
The workflow reads one caller-selected local file. It adds no target execution,
injection, mutation, protection recovery, network access, volume mount or live
filesystem access, so no maintainer scope decision is required.
`inspect_artifact` routing for every existing format is unchanged, and the new
format id appears only for bytes whose first record carries the `FILE`
signature.
## References
- https://learn.microsoft.com/en-us/windows/win32/fileio/master-file-table
- https://github.com/libyal/libfsntfs/blob/main/documentation/New%20Technologies%20File%20System%20(NTFS).asciidoc
-51
View File
@@ -1,51 +0,0 @@
# NTFS $MFT and USN Journal inspection
This specification records an implementation increment and its original problem
statement. For current behavior and limitations, see the artifact inventory
guide.
## Problem and outcome
REA has no reader for acquired NTFS metadata artifacts. An analyst holding a
captured `$MFT` stream cannot ask which FILE records it contains, whether their
update-sequence fixups survived acquisition, or which records are directories,
extensions of another record, or no longer in use. Add offline, read-only
inventory of a selected `$MFT` file, delivered in steps so each one merges on
its own.
## Requirements
- Recognize a `$MFT` stream by a plausible first `FILE` record header under
any file name. CLI and MCP target admission route it to the artifact reader.
- Validate each FILE record header: the `FILE` signature, sequence number, flags,
used and total byte counts, base record reference, and the update-sequence
array's offset, count and array bounds.
- Verify the update-sequence fixups: every sector trailer the array covers must
hold the array's sequence number. A mismatch keeps the record's header facts
and is reported per record and in the metadata count.
- Report a missing, misplaced or short update-sequence array as unverified
rather than as a clean record, and a trailing partial record as a byte count.
- Report the first invalid record signature and the number of invalid slots.
Continue at the next slot when the stream declared a supported record size.
With a fallback record size, stop at the first invalid signature.
- Bound the whole file at a fixed 512 MiB ceiling and report the ceiling as a
`limit` failure with its boundary.
- Return the first 32 decoded record headers with their source offsets and the
number of omitted headers. Aggregate counts cover the full parsed stream.
- Parse only the records' headers and fixups. Attributes, filenames, data
streams and the `$UsnJrnl:$J` journal are not decoded in this step, so no path,
file size, file reference correlation or change history is claimed.
## Acceptance
Synthesized fixtures cover a clean record, a failed fixup, a missing update
sequence array, an array shorter than the record's sector count, extension and
directory records, a record whose signature is wrong mid-stream, a trailing
partial record, an undeclared record size, a 4 KiB record, a stream shorter than
one header, bytes without the `FILE` signature, and the file-size ceiling.
Inventory tests cover the root occurrence's metadata and the coverage status
when a fixup fails, and classification tests cover several file names for the
same bytes.
Not covered in this step: real `$MFT` files written by a Windows host, USN
journal records, attribute decoding, and any correlation between the two.
-11
View File
@@ -1,11 +0,0 @@
# Tasks
- [x] Define the first-step scope, contract and limits.
- [x] Parse FILE record headers and update-sequence fixups with synthesized
fixtures and explicit limitations.
- [x] Add the reader, format descriptor and byte classification.
- [ ] Decode attribute records, then `$FILE_NAME` attributes with parent
references, names, hard links and timestamps.
- [ ] Parse `$UsnJrnl:$J` records and correlate them to records by full file
reference.
- [ ] Verify against real `$MFT` and journal captures from a Windows host.
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"rea": {
"command": "npx",
"args": [
"-y",
"[email protected]",
"mcp"
]
}
}
}
+4 -1
View File
@@ -110,6 +110,9 @@
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
".codex-plugin/mcp.json",
".cursor-plugin/plugin.json",
".cursor-plugin/skills",
"mcp.json",
"scripts/plugin-mcp-launcher.mjs",
"npm-shrinkwrap.json"
],
@@ -294,7 +297,7 @@
"docs:preview": "vitepress preview docs",
"docs:generate": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts npm run docs:prepare",
"docs:check": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run docs:check:built && npm run test:local -- tests/conformance/readmeConsistency.test.ts",
"docs:check:built": "node scripts/generate-package-metadata.mjs --check && node scripts/generate-skill-metadata.mjs --check && node scripts/generate-product-catalog.mjs --check && node scripts/generate-completion-ledger.mjs --check && node scripts/check-doc-facts.mjs && node scripts/generate-error-schema.mjs --check && npm run licenses:check",
"docs:check:built": "node scripts/generate-package-metadata.mjs --check && node scripts/generate-skill-metadata.mjs --check && node scripts/generate-cursor-plugin-skill.mjs --check && node scripts/generate-product-catalog.mjs --check && node scripts/generate-completion-ledger.mjs --check && node scripts/check-doc-facts.mjs && node scripts/generate-error-schema.mjs --check && npm run licenses:check",
"docs:error-schema": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run artifacts:error-schema",
"prepack": "node scripts/prepack.mjs",
"prepare": "node scripts/prepare.mjs",
+10
View File
@@ -52,6 +52,16 @@
"path": ".codex-plugin/mcp.json",
"jsonpath": "$.mcpServers.rea.args[1]"
},
{
"type": "json",
"path": ".cursor-plugin/plugin.json",
"jsonpath": "$.version"
},
{
"type": "json",
"path": "mcp.json",
"jsonpath": "$.mcpServers.rea.args[1]"
},
{
"type": "json",
"path": ".claude-plugin/marketplace.json",
+1 -1
View File
@@ -636,7 +636,7 @@ const PATH_SCOPES = {
"src/ghidra/GhidraJavaProperties.ts",
"src/ghidra/GhidraReleasePolicy.ts",
"scripts/verify-ghidra-bridge-sources.mjs",
"tests/conformance/ghidra/ReaInstructionContextProbe.java",
"tests/conformance/ghidra/ReaArmContextProbe.java",
"tests/conformance/ghidra/ReaComputedCallCoverageProbe.java",
"tests/conformance/ghidra/ReaDecodeProbe.java",
".github/workflows/ghidra-bridge-sources.yml",
+14
View File
@@ -0,0 +1,14 @@
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { syncCursorPluginSkill } from "./lib/cursor-plugin-bundle.mjs";
const arguments_ = new Set(process.argv.slice(2));
for (const argument of arguments_)
if (argument !== "--check")
throw new Error(`Unknown Cursor plugin skill option: ${argument}`);
await syncCursorPluginSkill({
root: join(dirname(fileURLToPath(import.meta.url)), ".."),
check: arguments_.has("--check"),
});
@@ -1,7 +0,0 @@
/** Remove copied authentication independently; retain ordinary files until commands release. */
export function closeAgentEvaluation(options: {
readonly commands: { close(): Promise<void> };
readonly authPath: string;
readonly evaluationRoot: string;
readonly keepFixtures: boolean;
}): Promise<void>;
-24
View File
@@ -1,24 +0,0 @@
import { rm } from "node:fs/promises";
import { closeVerifierResources } from "./verifier-cleanup.mjs";
/** Remove copied authentication independently; retain ordinary files until commands release. */
export async function closeAgentEvaluation({
commands,
authPath,
evaluationRoot,
keepFixtures,
}) {
let commandsReleased = false;
await closeVerifierResources([
async () => {
await commands.close();
commandsReleased = true;
},
() => rm(authPath, { force: true }),
async () => {
if (commandsReleased && !keepFixtures)
await rm(evaluationRoot, { recursive: true, force: true });
},
]);
}
+79
View File
@@ -0,0 +1,79 @@
export const CURSOR_PLUGIN_MANIFEST: ".cursor-plugin/plugin.json";
export const CURSOR_PLUGIN_MCP: "mcp.json";
export const AUTHORED_SKILL_DIRECTORY: ".agents/skills/reverse-engineer-anything";
export const CURSOR_PLUGIN_SKILLS: ".cursor-plugin/skills";
export const CURSOR_PLUGIN_SKILL: "reverse-engineer-anything";
export const CURSOR_PLUGIN_LOGO: "assets/rea-plugin.png";
export const CURSOR_PLUGIN_REPOSITORY: "https://github.com/morluto/rea";
export interface CursorPluginBundleIssue {
readonly field: string;
}
export interface CursorPluginBundleInput {
readonly manifest: {
readonly name?: string;
readonly version?: string;
readonly description?: string;
readonly author?: { readonly name?: string };
readonly repository?: string;
readonly license?: string;
readonly keywords?: readonly string[];
readonly skills?: string;
readonly logo?: string;
readonly mcpServers?: unknown;
};
readonly mcp: {
readonly mcpServers?: {
readonly rea?: {
readonly command?: string;
readonly args?: readonly string[];
};
};
};
readonly packageVersion: string;
readonly logoExists: boolean;
readonly skillNames: readonly string[];
readonly skillFrontmatter: {
readonly name?: string;
readonly description?: string;
} | null;
}
export interface CursorPluginInspection {
readonly manifest: CursorPluginBundleInput["manifest"] & {
readonly version: string;
};
readonly mcp: {
readonly mcpServers: {
readonly rea: {
readonly command: string;
readonly args: readonly string[];
};
};
};
readonly packageVersion: string;
readonly issues: readonly CursorPluginBundleIssue[];
}
export function cursorPluginSkillPaths(
directory: string,
prefix?: string,
): Promise<readonly string[] | null>;
export function cursorPluginBundleIssues(
input: CursorPluginBundleInput,
): readonly CursorPluginBundleIssue[];
export function syncCursorPluginSkill(options: {
readonly root: string;
readonly check: boolean;
}): Promise<void>;
export function inspectCursorPluginBundle(
root: string,
): Promise<CursorPluginInspection>;
export function cursorMarketplaceInstallLinkIssues(
documents: readonly { readonly path: string; readonly text: string }[],
): readonly { readonly path: string; readonly marker: string }[];
+216
View File
@@ -0,0 +1,216 @@
import { mkdir, readFile, readdir, rm, stat } from "node:fs/promises";
import { dirname, join } from "node:path";
import { ensureGeneratedFile } from "./generated-file.mjs";
// Identity fields follow the shared plugin manifest checked for local Cursor
// publication (name, release version, repository, skill, and icon). The
// Marketplace checkout has no dist/, so the server is the pinned npm command
// instead of the bundled launcher.
export const CURSOR_PLUGIN_MANIFEST = ".cursor-plugin/plugin.json";
export const CURSOR_PLUGIN_MCP = "mcp.json";
export const AUTHORED_SKILL_DIRECTORY =
".agents/skills/reverse-engineer-anything";
export const CURSOR_PLUGIN_SKILLS = ".cursor-plugin/skills";
export const CURSOR_PLUGIN_SKILL = "reverse-engineer-anything";
export const CURSOR_PLUGIN_LOGO = "assets/rea-plugin.png";
export const CURSOR_PLUGIN_REPOSITORY = "https://github.com/morluto/rea";
const GENERATE_COMMAND = "node scripts/generate-cursor-plugin-skill.mjs";
const INSTALL_LINK_MARKERS = ["install-mcp", "deeplink/mcp", "mcp-install"];
const failure = (code, detail) => {
const error = new Error(`${code}: ${detail}`);
error.code = code;
return error;
};
const cursorServerArgs = (version) => ["-y", `rea-agents@${version}`, "mcp"];
/** Relative file inventory for one skill directory, in stable order. */
export const cursorPluginSkillPaths = async (directory, prefix = "") => {
const paths = [];
let entries;
try {
entries = await readdir(directory, { withFileTypes: true });
} catch (cause) {
if (cause?.code === "ENOENT") return null;
throw cause;
}
for (const entry of entries.sort((left, right) =>
left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
)) {
const relativePath = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
if (entry.isDirectory())
paths.push(
...(await cursorPluginSkillPaths(
join(directory, entry.name),
relativePath,
)),
);
else if (entry.isFile()) paths.push(relativePath);
else
throw failure(
"cursor_plugin_skill_inventory",
`Skill bundle requires regular files or directories: ${relativePath}`,
);
}
return paths;
};
const skillFrontmatter = (content) => {
const normalized = content.replaceAll("\r\n", "\n");
if (!normalized.startsWith("---\n")) return null;
const closing = normalized.indexOf("\n---\n", 4);
if (closing === -1) return null;
const fields = {};
for (const line of normalized.slice(4, closing).split("\n")) {
const separator = line.indexOf(":");
if (separator === -1) continue;
const key = line.slice(0, separator).trim();
const value = line.slice(separator + 1).trim();
if (key !== "" && fields[key] === undefined) fields[key] = value;
}
return fields;
};
/**
* Structured differences between the Cursor plugin declaration and the
* package version it must launch. Empty means the manifest, server pin,
* logo, and skill frontmatter agree.
*/
export const cursorPluginBundleIssues = ({
manifest,
mcp,
packageVersion,
logoExists,
skillNames,
skillFrontmatter: frontmatter,
}) => {
const issues = [];
const add = (field) => issues.push({ field });
if (manifest?.name !== "rea") add("name");
if (manifest?.version !== packageVersion) add("version");
if (typeof manifest?.description !== "string" || manifest.description === "")
add("description");
if (typeof manifest?.author?.name !== "string" || manifest.author.name === "")
add("author.name");
if (manifest?.repository !== CURSOR_PLUGIN_REPOSITORY) add("repository");
if (typeof manifest?.license !== "string" || manifest.license === "")
add("license");
if (
!Array.isArray(manifest?.keywords) ||
manifest.keywords.length === 0 ||
manifest.keywords.some((keyword) => typeof keyword !== "string")
)
add("keywords");
if (manifest?.skills !== CURSOR_PLUGIN_SKILLS) add("skills");
if (manifest?.logo !== CURSOR_PLUGIN_LOGO) add("logo");
if (Object.hasOwn(manifest ?? {}, "mcpServers")) add("mcpServers");
if (logoExists !== true) add("logo.file");
const server = mcp?.mcpServers?.rea;
if (
server?.command !== "npx" ||
JSON.stringify(server?.args) !==
JSON.stringify(cursorServerArgs(packageVersion))
)
add("mcp.args");
if (
!Array.isArray(skillNames) ||
skillNames.length !== 1 ||
skillNames[0] !== CURSOR_PLUGIN_SKILL
)
add("skills.entries");
if (frontmatter?.name !== CURSOR_PLUGIN_SKILL) add("skill.name");
if (
typeof frontmatter?.description !== "string" ||
frontmatter.description === ""
)
add("skill.description");
return issues;
};
/** Copy the authored skill into the Marketplace plugin tree, or fail when it drifted. */
export const syncCursorPluginSkill = async ({ root, check }) => {
const sourceRoot = join(root, AUTHORED_SKILL_DIRECTORY);
const outputRoot = join(root, CURSOR_PLUGIN_SKILLS, CURSOR_PLUGIN_SKILL);
const sourcePaths = await cursorPluginSkillPaths(sourceRoot);
if (sourcePaths === null)
throw failure(
"cursor_plugin_skill_inventory",
`Authored skill is missing: ${AUTHORED_SKILL_DIRECTORY}`,
);
if (!check) await rm(outputRoot, { recursive: true, force: true });
for (const relativePath of sourcePaths) {
const source = (
await readFile(join(sourceRoot, relativePath), "utf8")
).replaceAll("\r\n", "\n");
if (!check)
await mkdir(dirname(join(outputRoot, relativePath)), { recursive: true });
try {
await ensureGeneratedFile({
path: join(outputRoot, relativePath),
source,
check,
generateCommand: GENERATE_COMMAND,
});
} catch (cause) {
throw failure(
"cursor_plugin_skill_stale",
cause instanceof Error ? cause.message : String(cause),
);
}
}
const outputPaths = await cursorPluginSkillPaths(outputRoot);
if (
outputPaths === null ||
JSON.stringify(outputPaths) !== JSON.stringify(sourcePaths)
)
throw failure(
"cursor_plugin_skill_inventory",
"Cursor plugin skill inventory drifted; run node scripts/generate-cursor-plugin-skill.mjs",
);
};
/** Read the committed plugin and report structured contract issues. */
export const inspectCursorPluginBundle = async (root) => {
const manifest = JSON.parse(
await readFile(join(root, CURSOR_PLUGIN_MANIFEST), "utf8"),
);
const mcp = JSON.parse(await readFile(join(root, CURSOR_PLUGIN_MCP), "utf8"));
const packageJson = JSON.parse(
await readFile(join(root, "package.json"), "utf8"),
);
const logoExists = await stat(join(root, CURSOR_PLUGIN_LOGO))
.then((metadata) => metadata.isFile())
.catch((cause) => {
if (cause?.code === "ENOENT") return false;
throw cause;
});
const skillDirectory = join(root, CURSOR_PLUGIN_SKILLS);
const skillNames = await readdir(skillDirectory);
const skillContent = await readFile(
join(skillDirectory, CURSOR_PLUGIN_SKILL, "SKILL.md"),
"utf8",
);
return {
manifest,
mcp,
packageVersion: packageJson.version,
issues: cursorPluginBundleIssues({
manifest,
mcp,
packageVersion: packageJson.version,
logoExists,
skillNames,
skillFrontmatter: skillFrontmatter(skillContent),
}),
};
};
/** MCP-only install links omit the skill, so Marketplace docs must not carry them. */
export const cursorMarketplaceInstallLinkIssues = (documents) =>
documents.flatMap(({ path, text }) =>
INSTALL_LINK_MARKERS.filter((marker) => text.includes(marker)).map(
(marker) => ({ path, marker }),
),
);
@@ -375,15 +375,11 @@ test("dispatch reverses origin completion; client and capture may arrive in othe
test("cleanup verifies and retires a separately owned producer group", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-http3-group-"));
const runId = randomUUID();
const child = spawn(
process.execPath,
["--eval", "setInterval(() => {}, 1000)"],
{
detached: true,
env: { ...process.env, REA_PROCESS_RUN_ID: runId },
stdio: "ignore",
},
);
const child = spawn("/usr/bin/sleep", ["60"], {
detached: true,
env: { ...process.env, REA_PROCESS_RUN_ID: runId },
stdio: "ignore",
});
let primaryError;
let cleaned = false;
try {
-1
View File
@@ -24,7 +24,6 @@ export interface RealHopperFixtureTargets {
readonly manifestPath: string;
readonly primary: RealHopperFixtureTarget;
readonly secondary: RealHopperFixtureTarget;
readonly unicode?: RealHopperFixtureTarget;
readonly large: RealHopperFixtureTarget;
readonly oracle: RealHopperFixtureOracle;
readonly largeOracle: RealHopperLargeFixtureOracle;
+2 -1
View File
@@ -34,8 +34,9 @@ def note_header(content, note, **changes):
# Authored metadata carriers exercise actual parser projection through public
# inspection, independently of the host that produced the captured amd64 core.
# The named ARCHEXT/UNWIND carriers are written once below using numeric type IDs.
for name, machine, kind in [
("aarch64-archext", "EM_AARCH64", "PT_AARCH64_ARCHEXT"),
("aarch64-unwind", "EM_AARCH64", "PT_AARCH64_UNWIND"),
("aarch64-mte-opaque", "EM_AARCH64", 0x70000002),
("amd64-processor-opaque", "EM_X86_64", 0x70000001),
]:
+31
View File
@@ -35,6 +35,16 @@ const codexServerConfigSchema = z.object({
}),
}),
});
const cursorPluginManifest = ".cursor-plugin/plugin.json";
const cursorPluginServer = "mcp.json";
const cursorServerConfigSchema = z.object({
mcpServers: z.object({
rea: z.object({
command: z.literal("npx"),
args: z.tuple([z.literal("-y"), z.string(), z.literal("mcp")]),
}),
}),
});
const sectionsSchema = z.object({
versioning: z.string().optional(),
"changelog-sections": z.array(
@@ -240,6 +250,20 @@ async function validateCandidate(git, sha, version, commits) {
: manifest;
launches.push([path, server.mcpServers.rea.args[1]]);
}
const cursorManifest = await readJson(
git,
sha,
cursorPluginManifest,
versionSchema,
);
versions.push([cursorPluginManifest, cursorManifest.version]);
const cursorServer = await readJson(
git,
sha,
cursorPluginServer,
cursorServerConfigSchema,
);
const cursorPin = cursorServer.mcpServers.rea.args[1];
// Catalogs are generated and checked from this candidate by the release job;
// they are not version authority stored in the immutable source tree.
const generated = await git([
@@ -262,6 +286,13 @@ async function validateCandidate(git, sha, version, commits) {
}
}
// The launcher checks the scalar pin against the runtime in its own package.
// Compare manifest versions first so a wholly stale candidate reports the
// shared version authority before this Marketplace package specifier.
if (cursorPin !== `rea-agents@${version}`) {
throw new Error(
`${cursorPluginServer}@${sha} launches ${cursorPin}; release branch requires rea-agents@${version}.`,
);
}
for (const [location, launched] of launches) {
if (launched !== version) {
throw new Error(
+10 -3
View File
@@ -373,6 +373,8 @@ const VERSION_ONLY_PLUGIN_METADATA = new Set([
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
".codex-plugin/mcp.json",
".cursor-plugin/plugin.json",
"mcp.json",
".claude-plugin/marketplace.json",
".agents/plugins/marketplace.json",
]);
@@ -395,9 +397,14 @@ function pluginMetadataWithoutVersions(path, text) {
}
if ("version" in manifest) manifest.version = "";
const launch = manifest.mcpServers?.rea?.args;
// Only the scalar pinned version is release-owned; the launcher owns the
// fixed package name and the executable/path remain product behavior.
if (Array.isArray(launch) && typeof launch[1] === "string") launch[1] = "";
// Only the scalar pinned version is release-owned. Blank that version and
// keep a package name such as rea-agents@ so a renamed package still counts
// as a product change. The executable and path remain product behavior.
if (Array.isArray(launch) && typeof launch[1] === "string")
launch[1] = launch[1].replace(
/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/u,
"",
);
return JSON.stringify(manifest);
}
+26 -33
View File
@@ -1,22 +1,19 @@
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { createVerifierMcpOwner } from "./verifier-mcp-owner.mjs";
import { withVerifierCleanup } from "./verifier-cleanup.mjs";
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { FULL_CATALOG_IDENTITY } from "../../dist/catalogIdentity.js";
import { verifyCompleteToolCatalog } from "./verify-package-core.mjs";
/** Install the selected archive or registry specifier with an owned ten-minute deadline. */
export async function installCanaryPackage(
specifier,
workspace,
environment,
commands,
) {
const execute = promisify(execFile);
/** Install the selected archive or registry specifier before any protocol deadline. */
export async function installCanaryPackage(specifier, workspace, environment) {
const prefix = join(workspace, "installed");
await commands.exec(
await execute(
"npm",
[
"install",
@@ -29,12 +26,7 @@ export async function installCanaryPackage(
prefix,
specifier,
],
{
cwd: workspace,
env: environment,
timeout: 600000,
maxBuffer: 16 * 1024 * 1024,
},
{ cwd: workspace, env: environment, maxBuffer: 16 * 1024 * 1024 },
);
return join(prefix, "node_modules", "rea-agents");
}
@@ -44,7 +36,7 @@ export async function verifyInstalledCanaryPackage(
packageRoot,
version,
workspace,
{ environment, commands },
environment,
) {
const installed = JSON.parse(
await readFile(join(packageRoot, "package.json"), "utf8"),
@@ -55,10 +47,9 @@ export async function verifyInstalledCanaryPackage(
);
const launcher = join(packageRoot, "scripts", "rea.mjs");
for (const option of ["--help", "--llms"])
await commands.exec(process.execPath, [launcher, option], {
await execute(process.execPath, [launcher, option], {
cwd: workspace,
env: environment,
timeout: 30000,
maxBuffer: 16 * 1024 * 1024,
});
const transport = new StdioClientTransport({
@@ -74,20 +65,22 @@ export async function verifyInstalledCanaryPackage(
if (stderr.length < 16_384)
stderr += chunk.toString("utf8").slice(0, 16_384 - stderr.length);
});
const owner = createVerifierMcpOwner();
commands.adopt(owner, "installed package MCP transport");
return withVerifierCleanup(async () => {
try {
await client.connect(transport);
const toolNames = await verifyCompleteToolCatalog(client);
return {
mcpTools: toolNames.length,
canonicalMcpTools: FULL_CATALOG_IDENTITY.counts.mcp_tools,
};
} catch (cause) {
throw new Error(`Installed package MCP verification failed: ${stderr}`, {
cause,
});
} finally {
try {
await owner.open(client, transport);
const toolNames = await verifyCompleteToolCatalog(client);
return {
mcpTools: toolNames.length,
canonicalMcpTools: FULL_CATALOG_IDENTITY.counts.mcp_tools,
};
} catch (cause) {
throw new Error(`Installed package MCP verification failed: ${stderr}`, {
cause,
});
await client.close();
} finally {
await transport.close();
}
}, [() => owner.close()]);
}
}
+4
View File
@@ -43,6 +43,10 @@ if (await pathExists(join(root, "src"))) {
throw new Error("Prepack could not resolve the active package manager");
run(process.execPath, [packageManagerPath, "run", "build:cached"]);
run(process.execPath, ["scripts/generate-skill-metadata.mjs", "--check"]);
run(process.execPath, [
"scripts/generate-cursor-plugin-skill.mjs",
"--check",
]);
} else {
for (const path of [
"scripts/lib/cli-process-environment.mjs",
+15 -9
View File
@@ -1,4 +1,3 @@
import { closeAgentEvaluation } from "./lib/agent-evaluation-cleanup.mjs";
import { importVerifierSource } from "./lib/verifier-source-loader.mjs";
import { createHash } from "node:crypto";
@@ -8,6 +7,7 @@ import {
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { homedir } from "node:os";
@@ -18,7 +18,10 @@ import { tsImport } from "tsx/esm/api";
import { PRODUCT_IDENTITY } from "../dist/identity.js";
import { createAgentEvaluationCommands } from "./lib/agent-evaluation-commands.mjs";
import { assertAgentEvaluationReportPassed } from "./lib/agent-evaluation-report-gate.mjs";
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
import {
closeVerifierResources,
withVerifierCleanup,
} from "./lib/verifier-cleanup.mjs";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
const { parseSkillManifestMetadata } = await importVerifierSource(
@@ -424,13 +427,16 @@ await withVerifierCleanup(
},
// Even a deliberately retained fixture directory must never retain auth.
[
() =>
closeAgentEvaluation({
commands,
authPath,
evaluationRoot,
keepFixtures: process.env.REA_AGENT_EVAL_KEEP_FIXTURES === "true",
}),
async () => {
// A failed process cleanup retains both its exact owner and its files.
await commands.close();
await closeVerifierResources([
() => rm(authPath, { force: true }),
...(process.env.REA_AGENT_EVAL_KEEP_FIXTURES === "true"
? []
: [() => rm(evaluationRoot, { recursive: true, force: true })]),
]);
},
],
);
-27
View File
@@ -6,7 +6,6 @@ import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { absoluteCallerPath } from "../dist/filesystem/callerPath.js";
import { toolContract } from "../dist/contracts/toolContracts.js";
import { il2cppMetadataViewRequestSchema } from "../dist/domain/analysisView/il2cppMetadataView.js";
import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js";
import {
requireMcpOutput,
@@ -80,32 +79,6 @@ await withVerifierCleanup(async () => {
assert.equal(error.code, "resource_constraint");
assert.equal(error.details.reported_limits.boundary, "mcp-response");
assert.match(error.details.reported_limits.evidence_id, /^ev_/u);
const collections = il2cppMetadataViewRequestSchema.options.map(
(option) => option.shape.collection.value,
);
const collection =
["methods", ...collections].find(
(name) => Array.isArray(report[name]) && report[name].length > 0,
) ?? "methods";
const expectedRows = report[collection].slice(0, 16);
const retained = (
await call("inspect_analysis_view", {
source: {
kind: "retained-evidence",
evidence_id: error.details.reported_limits.evidence_id,
},
view: { kind: "il2cpp-metadata", collection, limit: 16 },
})
).normalized_result;
assert.deepEqual(
retained.items,
expectedRows.map((row, index) => ({ ...row, index })),
"Failed complete delivery must retain its exact Evidence before reanalysis",
);
if (expectedRows.length === 0)
console.log(
"[unverified] Retention lookup succeeded, but empty queryable tables provide no nonempty row-content witness",
);
console.log(
`[ok] MCP complete delivery refused at the stdio budget (${error.details.reported_limits.response_bytes_at_least} bytes) with retained Evidence`,
);
+3 -10
View File
@@ -165,9 +165,7 @@ try {
scenario.value.steps.some((step) => step.outcome !== "completed") ||
!scenario.value.steps
.at(-1)
?.after?.nodes.some(
(node) => node.title === "REA fixture incremented 1",
)
?.after?.nodes.some((node) => node.title === "REA fixture incremented")
)
throw new Error(
`Source-owned AX scenario failed: ${JSON.stringify(scenario.value.steps.map(({ outcome, reason }) => ({ outcome, reason })))}`,
@@ -197,12 +195,7 @@ try {
evidence.normalized_result?.steps?.some(
(step) => step.outcome !== "completed",
) ||
evidence.normalized_result?.steps?.length !== 2 ||
!evidence.normalized_result?.steps
?.at(-1)
?.after?.nodes.some(
(node) => node.title === "REA fixture incremented 2",
)
evidence.normalized_result?.steps?.length !== 2
)
throw new Error(
`Native UI CLI did not preserve the selected scenario: ${JSON.stringify(evidence.error ?? evidence.normalized_result?.steps?.map(({ outcome, reason }) => ({ outcome, reason })))}`,
@@ -239,7 +232,7 @@ try {
!captured.steps
.at(-1)
?.after?.nodes.some(
(node) => node.title === "REA fixture incremented 3",
(node) => node.title === "REA fixture incremented",
)
)
throw new Error("MCP did not observe the selected fixture's AX action");
+33
View File
@@ -266,6 +266,39 @@ export async function verifyPackagePack({ root, workspace, commands }) {
)
throw new Error(`Packaged plugin ${path} is missing its shared icon`);
}
const cursorManifest = JSON.parse(
(
await commands.exec("tar", [
"-xOf",
tarball,
"package/.cursor-plugin/plugin.json",
])
).stdout,
);
const cursorServer = JSON.parse(
(await commands.exec("tar", ["-xOf", tarball, "package/mcp.json"])).stdout,
);
const cursorLogo = String(cursorManifest.logo ?? "").replace(/^\.\//u, "");
const cursorSkill =
"package/.cursor-plugin/skills/reverse-engineer-anything/SKILL.md";
if (
cursorManifest.version !== packedManifest.version ||
cursorManifest.skills !== ".cursor-plugin/skills" ||
cursorLogo !== "assets/rea-plugin.png" ||
cursorManifest.mcpServers !== undefined ||
cursorServer.mcpServers?.rea?.command !== "npx" ||
JSON.stringify(cursorServer.mcpServers?.rea?.args) !==
JSON.stringify([
"-y",
`${packedManifest.name}@${packedManifest.version}`,
"mcp",
]) ||
!packedFiles.includes(`package/${cursorLogo}`) ||
!packedFiles.includes(cursorSkill)
)
throw new Error(
"Packaged Cursor plugin does not ship the pinned server, skill, and logo",
);
if (packedFiles.includes("package/.mcp.json"))
throw new Error("Plugin metadata must not register a project MCP server");
if (!packedFiles.includes("package/.codex-plugin/mcp.json"))
+15 -30
View File
@@ -1,8 +1,8 @@
import { createPackageVerifierCommands } from "./lib/verify-package-commands.mjs";
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { promisify } from "node:util";
import { pathToFileURL } from "node:url";
import { lt, prerelease, rcompare, valid } from "semver";
@@ -15,7 +15,7 @@ import {
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
const verifierRun = createVerifierRun();
const execFileAsync = promisify(execFile);
const version = process.argv[2];
if (
typeof version !== "string" ||
@@ -30,33 +30,28 @@ const serverEnvironment = {
npm_config_cache: join(canaryRoot, "npm-cache"),
};
delete serverEnvironment.HOPPER_TARGET_PATH;
const commands = await createPackageVerifierCommands({
workspace: {
path: canaryRoot,
close: () => rm(canaryRoot, { recursive: true, force: true }),
},
});
let surface;
let update;
await withVerifierCleanup(async () => {
try {
const publishedRoot = await installCanaryPackage(
`rea-agents@${version}`,
canaryRoot,
serverEnvironment,
commands,
);
surface = await verifyInstalledCanaryPackage(
publishedRoot,
version,
canaryRoot,
{ environment: serverEnvironment, commands },
serverEnvironment,
);
update = await verifyPublishedUpdate({
version,
canaryRoot,
updaterRoot: publishedRoot,
});
}, [() => commands.close()]);
} finally {
await rm(canaryRoot, { recursive: true, force: true });
}
process.stdout.write(
`${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), package: "rea-agents", version, ...surface, update })}\n`,
);
@@ -99,7 +94,7 @@ async function verifyPublishedUpdate({
environment,
);
await commands.exec(
await execFileAsync(
"npm",
[
"install",
@@ -109,20 +104,13 @@ async function verifyPublishedUpdate({
prefix,
`rea-agents@${previousVersion}`,
],
{
cwd: canaryRoot,
env: environment,
timeout: 600000,
maxBuffer: 16 * 1024 * 1024,
},
{ cwd: canaryRoot, env: environment, maxBuffer: 16 * 1024 * 1024 },
);
const npmRoot = (
await commands.exec("npm", ["root", "--global"], {
await execFileAsync("npm", ["root", "--global"], {
cwd: canaryRoot,
env: environment,
timeout: 30000,
maxBuffer: 16 * 1024 * 1024,
})
).stdout.trim();
// Older releases may predate the update command. Exercise the new published
@@ -138,10 +126,9 @@ process.stdout.write(JSON.stringify(result));
if (result.status === "failed") process.exitCode = 1;
`,
);
const { stdout } = await commands.exec(process.execPath, [harness], {
const { stdout } = await execFileAsync(process.execPath, [harness], {
cwd: canaryRoot,
env: environment,
timeout: 600000,
maxBuffer: 16 * 1024 * 1024,
});
const result = JSON.parse(stdout);
@@ -165,11 +152,9 @@ if (result.status === "failed") process.exitCode = 1;
);
const help = (
await commands.exec(join(prefix, "bin", "rea-agents"), ["--help"], {
await execFileAsync(join(prefix, "bin", "rea-agents"), ["--help"], {
cwd: canaryRoot,
env: environment,
timeout: 30000,
maxBuffer: 16 * 1024 * 1024,
})
).stdout;
if (!/^\s{2}update\s/mu.test(help) || /^\s{2}upgrade\s/mu.test(help))
@@ -186,10 +171,10 @@ if (result.status === "failed") process.exitCode = 1;
}
async function findPreviousPublishedVersion(targetVersion, cwd, environment) {
const { stdout } = await commands.exec(
const { stdout } = await execFileAsync(
"npm",
["view", "rea-agents", "versions", "--json"],
{ cwd, env: environment, timeout: 30000, maxBuffer: 16 * 1024 * 1024 },
{ cwd, env: environment, maxBuffer: 16 * 1024 * 1024 },
);
const versions = JSON.parse(stdout);
const previous = versions
+17 -26
View File
@@ -1,8 +1,4 @@
#!/usr/bin/env node
import {
closeVerifierResources,
withVerifierCleanup,
} from "./lib/verifier-cleanup.mjs";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
@@ -50,7 +46,7 @@ transport.stderr?.on("data", (bytes) => {
const ajv = new Ajv2020({ strict: false, validateFormats: false });
const measurements = [];
const contract = toolContract("inspect_web_network_capture");
const report = await withVerifierCleanup(async () => {
try {
try {
await client.connect(transport);
} catch (cause) {
@@ -196,7 +192,7 @@ const report = await withVerifierCleanup(async () => {
});
}
await client.ping();
return {
const report = {
status: "passed",
profile: "aioquic-1.5.0-json-0.3",
measurements,
@@ -206,23 +202,18 @@ const report = await withVerifierCleanup(async () => {
"No PCAP or keylog is read by this verifier; repeated STREAM coverage is not proof of retransmission or network loss.",
],
};
}, [
async () => {
await closeVerifierResources([
() => client.close(),
() => transport.close(),
]);
},
]);
await writeFile(reportPath, JSON.stringify(report, null, 2) + "\n", {
flag: "wx",
mode: 0o600,
});
console.log(
JSON.stringify({
status: report.status,
cases: measurements.length,
report: reportPath,
}),
);
await writeFile(reportPath, JSON.stringify(report, null, 2) + "\n", {
flag: "wx",
mode: 0o600,
});
console.log(
JSON.stringify({
status: report.status,
cases: measurements.length,
report: reportPath,
}),
);
} finally {
await client.close();
await transport.close();
}
+5 -14
View File
@@ -310,6 +310,7 @@ await withVerifierCleanup(async () => {
role: "assistant",
content: "REA_CLIENT_COMPATIBILITY_OK",
};
finalSeen = true;
} else if (skill && !skillRequested) {
skillRequested = true;
message = call(skill.name, { skill: "reverse-engineer-anything" });
@@ -400,13 +401,11 @@ await withVerifierCleanup(async () => {
stderr: join(lab, `${mode}.stderr`),
},
);
const events = response.stdout
const initialized = response.stdout
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line));
const initialized = events.find(
(v) => v.type === "system" && v.subtype === "init",
);
.map((line) => JSON.parse(line))
.find((v) => v.type === "system" && v.subtype === "init");
assert(initialized);
const observed = initialized.tools
.filter((n) => n.startsWith("mcp__rea__"))
@@ -418,15 +417,7 @@ await withVerifierCleanup(async () => {
"Qwen must discover the complete REA catalog",
);
assert.equal(fixture.failure, undefined);
const result = events.findLast((event) => event.type === "result");
assert.equal(
result?.subtype,
"success",
"Qwen must emit a successful terminal result",
);
assert.equal(result.is_error, false);
assert.equal(result.result, "REA_CLIENT_COMPATIBILITY_OK");
finalSeen = true;
assert(finalSeen);
if (mode === "call") {
assert(evidenceSeen);
assert(selectedSkillLoaded);
+14 -26
View File
@@ -1,12 +1,8 @@
#!/usr/bin/env node
import {
closeVerifierResources,
withVerifierCleanup,
} from "./lib/verifier-cleanup.mjs";
import assert from "node:assert/strict";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { createPackageVerifierCommands } from "./lib/verify-package-commands.mjs";
import { execFile } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import {
access,
@@ -18,16 +14,17 @@ import {
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { createServer } from "../dist/server/createServer.js";
const execFileAsync = promisify(execFile);
const cli = resolve("scripts/rea.mjs");
const runCli = async (...arguments_) => {
const { stdout } = await commands.exec(
const { stdout } = await execFileAsync(
process.execPath,
[cli, ...arguments_, "--format", "json"],
{
timeout: 150_000,
encoding: "utf8",
maxBuffer: 4 * 1024 * 1024,
env: process.env,
},
@@ -59,14 +56,8 @@ if (process.env.EXPECTED_PLATFORM !== undefined) {
);
}
const projectRoot = await mkdtemp(join(tmpdir(), "rea-cutter-smoke-"));
const commands = await createPackageVerifierCommands({
workspace: {
path: projectRoot,
close: () => rm(projectRoot, { recursive: true, force: true }),
},
});
const projectPath = join(projectRoot, "smoke.rzdb");
await withVerifierCleanup(async () => {
try {
const discovery = await runCli("list-cutter-sessions");
assert.equal(
discovery.discovery_status,
@@ -120,15 +111,7 @@ await withVerifierCleanup(async () => {
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const mcpOwner = {
close: () =>
closeVerifierResources([
() => mcpClient.close(),
() => mcpServer.close(),
]),
};
commands.adopt(mcpOwner, "Cutter verifier MCP server");
await withVerifierCleanup(async () => {
try {
await mcpServer.connect(serverTransport);
await mcpClient.connect(clientTransport);
const mcpDiscovery = await mcpClient.callTool({
@@ -155,7 +138,10 @@ await withVerifierCleanup(async () => {
inspection.normalized_result,
"Cutter CLI and MCP returned different analysis results.",
);
}, [() => mcpOwner.close()]);
} finally {
await mcpClient.close();
await mcpServer.close();
}
const annotation = `rea-smoke-${randomUUID()}`;
await runCli(
@@ -226,4 +212,6 @@ await withVerifierCleanup(async () => {
mode: 0o600,
});
console.log(JSON.stringify(report));
}, [() => commands.close()]);
} finally {
await rm(projectRoot, { recursive: true, force: true });
}
+326 -361
View File
@@ -1,7 +1,3 @@
import {
closeVerifierResources,
withVerifierCleanup,
} from "./lib/verifier-cleanup.mjs";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
@@ -71,428 +67,397 @@ let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
let connected = false;
let verifierRun;
let report;
await withVerifierCleanup(async () => {
try {
await exec(
ca65,
[join(root, "tests/conformance/ghidra/6502.s"), "-o", object],
{ timeout: 10000 },
try {
await exec(
ca65,
[join(root, "tests/conformance/ghidra/6502.s"), "-o", object],
{ timeout: 10000 },
);
await exec(
ld65,
["-C", join(root, "tests/conformance/ghidra/6502.cfg"), "-o", path, object],
{ timeout: 10000 },
);
const bytes = await readFile(path);
// Independently known opcodes and link addresses from the source fixture.
assert.equal(bytes.toString("hex"), "a907201080c90ad003a92a60a9006018690360");
const sha256 = createHash("sha256").update(bytes).digest("hex");
await client.connect(transport);
const opened = await call("open_binary", {
path,
format: "raw-binary",
architecture: "6502",
entry_point: "0x008001",
provider_id: "ghidra",
});
assert.equal(opened.architecture, "6502");
assert.equal(opened.entry_point, "0x8001");
const status = await call("binary_session");
const image = evidence(await call("inspect_native_load_image"), sha256);
assert.equal(
image.normalized_result.status,
"verified",
JSON.stringify(image.normalized_result),
);
assert.equal(image.normalized_result.architecture, "6502");
assert.equal(image.normalized_result.base_address, "0x8001");
assert.equal(image.normalized_result.language_id, "6502:LE:16:default");
assert.equal(image.normalized_result.compiler_spec_id, "default");
assert.equal(image.normalized_result.file_bytes, bytes.length);
const memory = evidence(
await call("read_bytes", { address: "0x8001", length: bytes.length }),
sha256,
);
assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex"));
const procedures = evidence(await call("list_procedures"), sha256);
for (const address of ["0x8001", "0x8010"])
assert.ok(
procedures.normalized_result.some(
(procedure) => procedure.address === address,
),
);
const instruction = evidence(
await call("inspect_native_instruction", { address: "0x8003" }),
sha256,
);
assert.equal(instruction.normalized_result.mnemonic.toUpperCase(), "JSR");
assert.ok(
instruction.normalized_result.references.some(
(reference) => reference.call && reference.target_address === "0x8010",
),
);
const pseudo = evidence(
await call("procedure_pseudo_code", { procedure: "0x8010" }),
sha256,
);
assert.match(pseudo.normalized_result, /\+\s*(?:3|0x3|'\\x03')/u);
const filePseudo = evidence(
await call("procedure_pseudo_code", { procedure: "file-offset:0xf" }),
sha256,
);
assert.equal(filePseudo.normalized_result, pseudo.normalized_result);
assert.ok(
filePseudo.locations.some(
(location) => location.kind === "file-offset" && location.offset === 15,
),
);
const rejected = await client.callTool({
name: "open_binary",
arguments: {
path,
format: "raw-binary",
architecture: "6502",
entry_point: "0xffff",
},
});
assert.equal(rejected.isError, true);
assert.match(JSON.stringify(rejected.content), /6502 address width/u);
const preserved = await call("binary_session");
assert.equal(preserved.analysis_run.run_id, status.analysis_run.run_id);
assert.equal(preserved.entry_point, "0x8001");
await call("close_binary");
await client.ping();
const { stdout } = await exec(
process.execPath,
[
entrypoint,
"inspect-native-load-image",
path,
"--target-format",
"raw-binary",
"--architecture",
"6502",
"--entry-point",
"0x8001",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const cli = parseEvidence(JSON.parse(stdout));
assert.deepEqual(cli.normalized_result, image.normalized_result);
assert.equal(cli.analysis_profile.digest, image.analysis_profile.digest);
assert.equal(cli.subject.digest.sha256, sha256);
const fileCli = await exec(
process.execPath,
[
entrypoint,
"decompile",
path,
"--file-offset=0xf",
"--target-format",
"raw-binary",
"--architecture",
"6502",
"--entry-point",
"0x8001",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const fileCliEvidence = parseEvidence(JSON.parse(fileCli.stdout));
assert.equal(fileCliEvidence.normalized_result, filePseudo.normalized_result);
assert.deepEqual(fileCliEvidence.locations, filePseudo.locations);
assert.equal(fileCliEvidence.subject.digest.sha256, sha256);
assert.deepEqual(await readFile(path), bytes);
assert.deepEqual(await readdir(runtime), []);
const lowMemory = [];
// BinaryLoader maps file bytes before trying processor-defined blocks. Prove
// that overlapping zero-page/stack definitions never overlay the selected image.
for (const [entry, fullAddressSpace] of [
[0xff, false],
[0, true],
]) {
const lowPath = join(workspace, `low-${entry}.bin`);
await exec(
ld65,
[
"-C",
join(root, "tests/conformance/ghidra/6502.cfg"),
"--start-addr",
String(entry),
"-o",
path,
lowPath,
object,
],
{ timeout: 10000 },
);
const bytes = await readFile(path);
// Independently known opcodes and link addresses from the source fixture.
assert.equal(
bytes.toString("hex"),
"a907201080c90ad003a92a60a9006018690360",
const linked = await readFile(lowPath);
const expectedCode = Buffer.from(
"a907200000c90ad003a92a60a9006018690360",
"hex",
);
const sha256 = createHash("sha256").update(bytes).digest("hex");
await client.connect(transport);
connected = true;
const opened = await call("open_binary", {
path,
expectedCode.writeUInt16LE(entry + 15, 3);
assert.deepEqual(linked, expectedCode);
const lowBytes = fullAddressSpace
? Buffer.concat([linked, Buffer.alloc(65536 - linked.length)])
: linked;
await writeFile(lowPath, lowBytes);
const lowSha256 = createHash("sha256").update(lowBytes).digest("hex");
const lowEntry = `0x${entry.toString(16)}`;
const lowOpened = await call("open_binary", {
path: lowPath,
format: "raw-binary",
architecture: "6502",
entry_point: "0x008001",
entry_point: lowEntry,
provider_id: "ghidra",
});
assert.equal(opened.architecture, "6502");
assert.equal(opened.entry_point, "0x8001");
const status = await call("binary_session");
const image = evidence(await call("inspect_native_load_image"), sha256);
assert.equal(lowOpened.entry_point, lowEntry);
const lowImage = evidence(
await call("inspect_native_load_image"),
lowSha256,
);
assert.equal(
image.normalized_result.status,
lowImage.normalized_result.status,
"verified",
JSON.stringify(image.normalized_result),
JSON.stringify(lowImage.normalized_result),
);
assert.equal(image.normalized_result.architecture, "6502");
assert.equal(image.normalized_result.base_address, "0x8001");
assert.equal(image.normalized_result.language_id, "6502:LE:16:default");
assert.equal(image.normalized_result.compiler_spec_id, "default");
assert.equal(image.normalized_result.file_bytes, bytes.length);
const memory = evidence(
await call("read_bytes", { address: "0x8001", length: bytes.length }),
sha256,
);
assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex"));
const procedures = evidence(await call("list_procedures"), sha256);
for (const address of ["0x8001", "0x8010"])
assert.ok(
procedures.normalized_result.some(
(procedure) => procedure.address === address,
),
assert.equal(lowImage.normalized_result.base_address, lowEntry);
assert.equal(lowImage.normalized_result.file_bytes, lowBytes.length);
const fileMappings =
lowImage.normalized_result.observations.mappings.filter(
(mapping) => mapping.source_file_index !== null,
);
const instruction = evidence(
await call("inspect_native_instruction", { address: "0x8003" }),
sha256,
assert.equal(fileMappings.length, 1);
assert.equal(
fileMappings[0].address_space,
lowImage.normalized_result.observations.default_address_space,
);
assert.equal(instruction.normalized_result.mnemonic.toUpperCase(), "JSR");
assert.equal(fileMappings[0].overlay, false);
assert.equal(fileMappings[0].start, lowEntry);
assert.equal(fileMappings[0].length, lowBytes.length);
const lowRead = evidence(
await call("read_bytes", { address: lowEntry, length: linked.length }),
lowSha256,
);
assert.equal(lowRead.normalized_result.bytes_hex, linked.toString("hex"));
const lowCall = evidence(
await call("inspect_native_instruction", {
address: `0x${(entry + 2).toString(16)}`,
}),
lowSha256,
);
assert.equal(lowCall.normalized_result.mnemonic.toUpperCase(), "JSR");
assert.ok(
instruction.normalized_result.references.some(
(reference) => reference.call && reference.target_address === "0x8010",
lowCall.normalized_result.references.some(
(reference) =>
reference.call &&
reference.target_address === `0x${(entry + 15).toString(16)}`,
),
);
const pseudo = evidence(
await call("procedure_pseudo_code", { procedure: "0x8010" }),
sha256,
);
assert.match(pseudo.normalized_result, /\+\s*(?:3|0x3|'\\x03')/u);
const filePseudo = evidence(
await call("procedure_pseudo_code", { procedure: "file-offset:0xf" }),
sha256,
);
assert.equal(filePseudo.normalized_result, pseudo.normalized_result);
assert.ok(
filePseudo.locations.some(
(location) => location.kind === "file-offset" && location.offset === 15,
),
);
const rejected = await client.callTool({
name: "open_binary",
arguments: {
path,
format: "raw-binary",
architecture: "6502",
entry_point: "0xffff",
},
});
assert.equal(rejected.isError, true);
assert.match(JSON.stringify(rejected.content), /6502 address width/u);
const preserved = await call("binary_session");
assert.equal(preserved.analysis_run.run_id, status.analysis_run.run_id);
assert.equal(preserved.entry_point, "0x8001");
await call("close_binary");
await client.ping();
const { stdout } = await exec(
const lowCli = await exec(
process.execPath,
[
entrypoint,
"inspect-native-load-image",
path,
lowPath,
"--target-format",
"raw-binary",
"--architecture",
"6502",
"--entry-point",
"0x8001",
lowEntry,
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const cli = parseEvidence(JSON.parse(stdout));
assert.deepEqual(cli.normalized_result, image.normalized_result);
assert.equal(cli.analysis_profile.digest, image.analysis_profile.digest);
assert.equal(cli.subject.digest.sha256, sha256);
const fileCli = await exec(
process.execPath,
[
entrypoint,
"decompile",
path,
"--file-offset=0xf",
"--target-format",
"raw-binary",
"--architecture",
"6502",
"--entry-point",
"0x8001",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
const lowCliEvidence = evidence(JSON.parse(lowCli.stdout), lowSha256);
assert.deepEqual(
lowCliEvidence.normalized_result,
lowImage.normalized_result,
);
const fileCliEvidence = parseEvidence(JSON.parse(fileCli.stdout));
assert.equal(
fileCliEvidence.normalized_result,
filePseudo.normalized_result,
lowCliEvidence.analysis_profile.digest,
lowImage.analysis_profile.digest,
);
assert.deepEqual(fileCliEvidence.locations, filePseudo.locations);
assert.equal(fileCliEvidence.subject.digest.sha256, sha256);
assert.deepEqual(await readFile(path), bytes);
assert.deepEqual(await readFile(lowPath), lowBytes);
assert.deepEqual(await readdir(runtime), []);
const lowMemory = [];
// BinaryLoader maps file bytes before trying processor-defined blocks. Prove
// that overlapping zero-page/stack definitions never overlay the selected image.
for (const [entry, fullAddressSpace] of [
[0xff, false],
[0, true],
]) {
const lowPath = join(workspace, `low-${entry}.bin`);
await exec(
ld65,
[
"-C",
join(root, "tests/conformance/ghidra/6502.cfg"),
"--start-addr",
String(entry),
"-o",
lowPath,
object,
],
{ timeout: 10000 },
);
const linked = await readFile(lowPath);
const expectedCode = Buffer.from(
"a907200000c90ad003a92a60a9006018690360",
"hex",
);
expectedCode.writeUInt16LE(entry + 15, 3);
assert.deepEqual(linked, expectedCode);
const lowBytes = fullAddressSpace
? Buffer.concat([linked, Buffer.alloc(65536 - linked.length)])
: linked;
await writeFile(lowPath, lowBytes);
const lowSha256 = createHash("sha256").update(lowBytes).digest("hex");
const lowEntry = `0x${entry.toString(16)}`;
const lowOpened = await call("open_binary", {
path: lowPath,
lowMemory.push({
entry: lowEntry,
bytes: lowBytes.length,
sha256: lowSha256,
});
}
let windowsReparseAdmission = "not-applicable";
if (process.platform === "win32") {
const junction = join(workspace, "linked");
await symlink(workspace, junction, "junction");
try {
const linkedPath = join(junction, "image.bin");
// Reopening the same bytes through a junction must replace the direct session.
await call("open_binary", {
path,
format: "raw-binary",
architecture: "6502",
entry_point: lowEntry,
entry_point: "0x8001",
provider_id: "ghidra",
});
assert.equal(lowOpened.entry_point, lowEntry);
const lowImage = evidence(
const directImage = evidence(
await call("inspect_native_load_image"),
lowSha256,
sha256,
);
assert.equal(
lowImage.normalized_result.status,
"verified",
JSON.stringify(lowImage.normalized_result),
assert.equal(directImage.normalized_result.status, "verified");
// Canonical file identity must not bypass admission of the new path.
await call("open_binary", {
path: linkedPath,
format: "raw-binary",
architecture: "6502",
entry_point: "0x8001",
provider_id: "ghidra",
});
// open_binary selects a lazy session; the first analysis admits the snapshot.
const linkedImage = await client.callTool(
{ name: "inspect_native_load_image", arguments: {} },
{ timeout: 240000 },
);
assert.equal(lowImage.normalized_result.base_address, lowEntry);
assert.equal(lowImage.normalized_result.file_bytes, lowBytes.length);
const fileMappings =
lowImage.normalized_result.observations.mappings.filter(
(mapping) => mapping.source_file_index !== null,
);
assert.equal(fileMappings.length, 1);
assert.equal(
fileMappings[0].address_space,
lowImage.normalized_result.observations.default_address_space,
);
assert.equal(fileMappings[0].overlay, false);
assert.equal(fileMappings[0].start, lowEntry);
assert.equal(fileMappings[0].length, lowBytes.length);
const lowRead = evidence(
await call("read_bytes", { address: lowEntry, length: linked.length }),
lowSha256,
);
assert.equal(lowRead.normalized_result.bytes_hex, linked.toString("hex"));
const lowCall = evidence(
await call("inspect_native_instruction", {
address: `0x${(entry + 2).toString(16)}`,
}),
lowSha256,
);
assert.equal(lowCall.normalized_result.mnemonic.toUpperCase(), "JSR");
assert.ok(
lowCall.normalized_result.references.some(
(reference) =>
reference.call &&
reference.target_address === `0x${(entry + 15).toString(16)}`,
),
assert.equal(linkedImage.isError, true);
assert.match(
JSON.stringify(linkedImage),
/Reparse path component is unsupported/u,
);
await call("close_binary");
const lowCli = await exec(
process.execPath,
[
entrypoint,
"inspect-native-load-image",
lowPath,
"--target-format",
"raw-binary",
"--architecture",
"6502",
"--entry-point",
lowEntry,
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const lowCliEvidence = evidence(JSON.parse(lowCli.stdout), lowSha256);
assert.deepEqual(
lowCliEvidence.normalized_result,
lowImage.normalized_result,
);
assert.equal(
lowCliEvidence.analysis_profile.digest,
lowImage.analysis_profile.digest,
);
assert.deepEqual(await readFile(lowPath), lowBytes);
assert.deepEqual(await readdir(runtime), []);
lowMemory.push({
entry: lowEntry,
bytes: lowBytes.length,
sha256: lowSha256,
const snapshotPath = join(workspace, "direct-snapshot.json");
const cliArgs = (selectedPath) => [
entrypoint,
"analyze",
selectedPath,
"--target-format",
"raw-binary",
"--architecture",
"6502",
"--entry-point",
"0x8001",
"--provider",
"ghidra",
"--snapshot",
snapshotPath,
"--json",
];
const saved = await exec(process.execPath, cliArgs(path), {
env,
timeout: 240000,
maxBuffer: 16 * 1024 * 1024,
});
}
let windowsReparseAdmission = "not-applicable";
if (process.platform === "win32") {
const junction = join(workspace, "linked");
await symlink(workspace, junction, "junction");
try {
const linkedPath = join(junction, "image.bin");
// Reopening the same bytes through a junction must replace the direct session.
await call("open_binary", {
path,
format: "raw-binary",
architecture: "6502",
entry_point: "0x8001",
provider_id: "ghidra",
});
const directImage = evidence(
await call("inspect_native_load_image"),
sha256,
);
assert.equal(directImage.normalized_result.status, "verified");
// Canonical file identity must not bypass admission of the new path.
await call("open_binary", {
path: linkedPath,
format: "raw-binary",
architecture: "6502",
entry_point: "0x8001",
provider_id: "ghidra",
});
// open_binary selects a lazy session; the first analysis admits the snapshot.
const linkedImage = await client.callTool(
{ name: "inspect_native_load_image", arguments: {} },
{ timeout: 240000 },
);
assert.equal(linkedImage.isError, true);
assert.match(
JSON.stringify(linkedImage),
/Reparse path component is unsupported/u,
);
await call("close_binary");
const snapshotPath = join(workspace, "direct-snapshot.json");
const cliArgs = (selectedPath) => [
entrypoint,
"analyze",
selectedPath,
"--target-format",
"raw-binary",
"--architecture",
"6502",
"--entry-point",
"0x8001",
"--provider",
"ghidra",
"--snapshot",
snapshotPath,
"--json",
];
const saved = await exec(process.execPath, cliArgs(path), {
assert.equal(
evidence(JSON.parse(saved.stdout), sha256).operation,
"binary_overview",
);
const savedSnapshot = JSON.parse(await readFile(snapshotPath, "utf8"));
assert.ok(
savedSnapshot.workflow_entries.some(
({ operation }) => operation === "binary_overview",
),
);
assert.deepEqual(await readdir(runtime), []);
// Replaying a matching snapshot must still reach native path admission.
await assert.rejects(
exec(process.execPath, cliArgs(linkedPath), {
env,
timeout: 240000,
maxBuffer: 16 * 1024 * 1024,
});
assert.equal(
evidence(JSON.parse(saved.stdout), sha256).operation,
"binary_overview",
);
const savedSnapshot = JSON.parse(await readFile(snapshotPath, "utf8"));
assert.ok(
savedSnapshot.workflow_entries.some(
({ operation }) => operation === "binary_overview",
),
);
assert.deepEqual(await readdir(runtime), []);
// Replaying a matching snapshot must still reach native path admission.
await assert.rejects(
exec(process.execPath, cliArgs(linkedPath), {
env,
timeout: 240000,
maxBuffer: 16 * 1024 * 1024,
}),
(cause) => {
assert.match(
cause.stdout + cause.stderr,
/Reparse path component is unsupported/u,
);
return true;
},
);
assert.deepEqual(await readFile(path), bytes);
assert.deepEqual(await readdir(runtime), []);
windowsReparseAdmission = "cli-snapshot-mcp-rejected";
} finally {
await unlink(junction);
}
}),
(cause) => {
assert.match(
cause.stdout + cause.stderr,
/Reparse path component is unsupported/u,
);
return true;
},
);
assert.deepEqual(await readFile(path), bytes);
assert.deepEqual(await readdir(runtime), []);
windowsReparseAdmission = "cli-snapshot-mcp-rejected";
} finally {
await unlink(junction);
}
report = {
ok: true,
mocked: false,
lane: "ghidra-6502",
host: process.platform,
toolchain,
provider: image.provider,
target: {
sha256,
bytes: bytes.length,
architecture: "6502",
entry: "0x8001",
},
cli_mcp_load_image_parity: true,
instructions_and_call_reference: true,
decompilation: true,
file_offset_decompilation_cli_mcp: true,
invalid_mapping_preserves_session: true,
low_memory_cli_mcp_parity: lowMemory,
windows_reparse_admission: windowsReparseAdmission,
source_unchanged: true,
runtime_cleanup: "complete",
};
} catch (cause) {
if (stderr) process.stderr.write(stderr);
throw cause;
}
}, [
async () => {
await closeVerifierResources([
async () => {
if (connected) await call("close_binary", {});
},
() => client.close(),
() => transport.close(),
]);
assert.deepEqual(
(await readdir(runtime)).filter((name) => name.startsWith("rea-ghidra-")),
[],
"Owned Ghidra runtime survived close",
);
verifierRun = await completeVerifierRun(run);
if (process.platform !== "win32") {
assert.equal(verifierRun.process_lineage.status, "verified");
assert.deepEqual(verifierRun.process_lineage.descendants, []);
}
await rm(workspace, { recursive: true, force: true });
},
]);
verifierRun = await completeVerifierRun(run);
report = {
ok: true,
mocked: false,
lane: "ghidra-6502",
host: process.platform,
toolchain,
provider: image.provider,
target: {
sha256,
bytes: bytes.length,
architecture: "6502",
entry: "0x8001",
},
cli_mcp_load_image_parity: true,
instructions_and_call_reference: true,
decompilation: true,
file_offset_decompilation_cli_mcp: true,
invalid_mapping_preserves_session: true,
low_memory_cli_mcp_parity: lowMemory,
windows_reparse_admission: windowsReparseAdmission,
source_unchanged: true,
runtime_cleanup: "complete",
};
} catch (cause) {
if (stderr) process.stderr.write(stderr);
throw cause;
} finally {
try {
await client.close();
} finally {
await transport.close();
}
}
const verifierRun = await completeVerifierRun(run);
if (process.platform !== "win32") {
assert.equal(verifierRun.process_lineage.status, "verified");
assert.deepEqual(verifierRun.process_lineage.descendants, []);
}
// Keep the workspace for diagnosis unless verification and cleanup both pass.
await rm(workspace, { recursive: true, force: true });
process.stdout.write(
JSON.stringify({ verifier_run: verifierRun, ...report }) + "\n",
);
+21 -14
View File
@@ -1,6 +1,4 @@
#!/usr/bin/env node
import { createVerifierCommands } from "./lib/verify-commands.mjs";
import { createVerifierMcpOwner } from "./lib/verifier-mcp-owner.mjs";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
@@ -63,7 +61,6 @@ for (const name of [
"REA_GHIDRA_DISABLED_ANALYZERS",
])
delete env[name];
const compilerCommands = await createVerifierCommands();
const report = {
verifier: "ghidra-owned-checkpoints",
phases: [],
@@ -122,7 +119,6 @@ async function refuseCli(procedure, flags, expected) {
return refusal;
}
async function connect(environment = {}) {
const owner = createVerifierMcpOwner();
const client = new Client({
name: "rea-ghidra-checkpoint-verifier",
version: "1",
@@ -134,15 +130,23 @@ async function connect(environment = {}) {
stderr: "pipe",
});
transport.stderr?.on("data", () => {});
compilerCommands.adopt(owner, "checkpoint MCP connection");
await owner.open(client, transport);
try {
await client.connect(transport);
} catch (cause) {
await client.close();
await transport.close();
throw cause;
}
const raw = (name, args = {}) =>
client.callTool({ name, arguments: args }, { timeout: 240000 });
return {
raw,
call: async (name, args = {}) =>
requireMcpOperationResult(await raw(name, args), name),
close: () => owner.close(),
close: async () => {
await client.close();
await transport.close();
},
};
}
let manifestPath;
@@ -156,11 +160,15 @@ __attribute__((noinline)) int rea_checkpoint_leaf(CheckpointRecord *record) { re
int main(void) { CheckpointRecord record = { 3, 11 }; return rea_checkpoint_leaf(&record); }
`,
);
await compilerCommands.exec(
"cc",
["-O0", "-g", "-gdwarf-4", "-fno-inline", sourcePath, "-o", target],
{ timeout: 120_000, maxBuffer: 16 * 1024 * 1024 },
);
await exec("cc", [
"-O0",
"-g",
"-gdwarf-4",
"-fno-inline",
sourcePath,
"-o",
target,
]);
const targetDigest = await sha(target);
report.target = {
sha256: targetDigest,
@@ -458,10 +466,9 @@ int main(void) { CheckpointRecord record = { 3, 11 }; return rea_checkpoint_leaf
report.failure_stderr = cause.stderr.slice(-4000);
process.exitCode = 1;
} finally {
report.run = await completeVerifierRun(run);
let cleanupVerified = false;
try {
await compilerCommands.close();
report.run = await completeVerifierRun(run);
assertVerifierCleanupComplete(report.run.process_lineage);
cleanupVerified = true;
} catch (cause) {
+363 -392
View File
@@ -1,8 +1,4 @@
#!/usr/bin/env node
import {
closeVerifierResources,
withVerifierCleanup,
} from "./lib/verifier-cleanup.mjs";
import assert from "node:assert/strict";
import {
requireMcpLifecycleResult,
@@ -11,14 +7,7 @@ import {
} from "./lib/mcp-verifier-results.mjs";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
mkdtemp,
mkdir,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -99,404 +88,386 @@ const transport = new StdioClientTransport({
stderr: "pipe",
});
const client = new Client({ name: "rea-real-com-proof", version: "1" });
let opened = false;
let stderr = "";
let connected = false;
let verifierRun;
let report;
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
await withVerifierCleanup(async () => {
try {
await client.connect(transport);
connected = true;
try {
await client.connect(transport);
const untyped = await client.callTool({
name: "open_binary",
arguments: { path: targetPath, provider_id: "ghidra" },
});
assert.equal(
untyped.isError,
true,
"Headerless bytes were guessed as COM without explicit interpretation",
const untyped = await client.callTool({
name: "open_binary",
arguments: { path: targetPath, provider_id: "ghidra" },
});
assert.equal(
untyped.isError,
true,
"Headerless bytes were guessed as COM without explicit interpretation",
);
const target = await call("open_binary", {
path: targetPath,
provider_id: "ghidra",
format: "dos-com",
});
opened = true;
assert.equal(target.format, "dos-com");
assert.equal(target.architecture, "x86");
assert.equal(target.sha256, sha256);
const image = await call("inspect_native_load_image");
assert.equal(
image.status,
"verified",
JSON.stringify(image.checks?.filter((c) => !c.matched)),
);
assert.equal(image.format, "dos-com");
assert.equal(image.header_bytes, 0);
assert.equal(image.module_bytes, fixture.bytes.length);
assert.equal(image.entry.linear_address, fixture.entry);
assert.equal(image.entry.offset, 256);
assert.equal(image.observations.entry_context[0].registers.length, 4);
const memory = await call("read_bytes", {
address: fixture.entry,
length: fixture.bytes.length,
});
assert.equal(memory.bytes_hex, fixture.bytes.toString("hex"));
assert.equal(memory.complete, true);
const mapping = await call("address_to_file_offset", {
address: fixture.near,
});
assert.equal(mapping.file_offset, 32);
const psp = await call("read_bytes", { address: "0x10000", length: 16 });
assert.equal(psp.returned_bytes, 0);
assert.equal(psp.complete, false);
const tail = await call("read_bytes", { address: "0x1013e", length: 8 });
assert.equal(tail.returned_bytes, 2);
assert.equal(tail.complete, false);
const procedures = await call("list_procedures");
assert.ok(procedures.some((p) => p.address === fixture.entry));
assert.ok(procedures.some((p) => p.address === fixture.near));
const dossier = functionDossierSchema.parse(
await call("analyze_function", {
procedure: fixture.entry,
detail: "complete",
}),
);
assert.ok(dossier.pseudocode.trim().length > 0);
assert.equal(dossier.procedure.body.available, true);
const instruction = await call("inspect_native_instruction", {
address: fixture.entry,
});
assert.equal(
instruction.bytes.replaceAll(" ", "").toLowerCase(),
fixture.entry_hex,
);
// This NOP follows the entry's terminating INT 20h and is not reached by
// either function. It remains valid instruction bytes outside the listing.
const unlistedAddress = "0x1013f";
const unlistedBytes = await call("read_bytes", {
address: unlistedAddress,
length: 1,
});
assert.equal(unlistedBytes.bytes_hex, "90");
const unlistedResponse = await client.callTool({
name: "inspect_native_instruction",
arguments: { address: unlistedAddress },
});
const unlistedEvidence = parseEvidence(
requireMcpOutput(unlistedResponse, "inspect_native_instruction"),
);
const unlisted = unlistedEvidence.normalized_result;
assert.equal(unlisted.status, "not-in-listing");
assert.equal(unlisted.bytes, null);
assert.ok(
unlisted.limitations.some((item) =>
item.includes("did not attempt a decode"),
),
);
const tools = (await client.listTools()).tools;
const advertised = tools.find(
(tool) => tool.name === "inspect_native_instruction",
);
assert.ok(advertised?.outputSchema);
const ajv = new Ajv2020({ strict: false, validateFormats: false });
assert.equal(ajv.validateSchema(advertised.outputSchema), true);
assert.equal(
ajv.validate(advertised.outputSchema, unlistedResponse.structuredContent),
true,
JSON.stringify(ajv.errors),
);
assert.ok(
nativeInstructionSchema.safeParse({ ...unlisted, status: "undecodable" })
.success,
);
const contextDefinition = tools.find(
(tool) => tool.name === "inspect_address_context",
);
assert.ok(contextDefinition?.outputSchema);
const documents = await call("list_documents");
assert.equal(documents.length, 1);
const document = documents[0];
for (const input of [
{ address: fixture.entry, document: `${document}-not-a-program` },
{ address: "not-an-address", document },
]) {
const direct = requireMcpToolError(
await client.callTool({ name: "address_name", arguments: input }),
);
const target = await call("open_binary", {
path: targetPath,
provider_id: "ghidra",
format: "dos-com",
});
assert.equal(target.format, "dos-com");
assert.equal(target.architecture, "x86");
assert.equal(target.sha256, sha256);
const image = await call("inspect_native_load_image");
assert.equal(
image.status,
"verified",
JSON.stringify(image.checks?.filter((c) => !c.matched)),
const response = await client.callTool(
{ name: "inspect_address_context", arguments: input },
{ timeout: 240000, toolDefinition: contextDefinition },
);
assert.equal(image.format, "dos-com");
assert.equal(image.header_bytes, 0);
assert.equal(image.module_bytes, fixture.bytes.length);
assert.equal(image.entry.linear_address, fixture.entry);
assert.equal(image.entry.offset, 256);
assert.equal(image.observations.entry_context[0].registers.length, 4);
const memory = await call("read_bytes", {
const context = requireMcpLifecycleResult(
response,
"inspect_address_context",
);
assert.equal(context.name.state, "unavailable");
assert.equal(context.name.error.code, "invalid_request");
assert.deepEqual(context.name.error.details.issues, direct.details.issues);
assert.equal(context.name.reason, direct.message);
assert.equal(context.name.remediation, direct.remediation.action);
}
assert.equal(
(await call("resolve_native_call_targets", { address: unlistedAddress }))
.status,
"unavailable",
);
assert.deepEqual(
(await cli("inspect-native-instruction", unlistedAddress))
.normalized_result,
unlisted,
);
const cliImage = await cli("inspect-native-load-image");
const stable = (x) => ({
...x,
observations: {
...x.observations,
source_files: x.observations.source_files.map(
({ name: _name, ...file }) => file,
),
},
});
assert.deepEqual(stable(cliImage.normalized_result), stable(image));
assert.deepEqual(
(
await cli("read-bytes", fixture.entry, [
"--length",
String(fixture.bytes.length),
])
).normalized_result,
memory,
);
assert.deepEqual(
(await cli("address-to-file-offset", fixture.near)).normalized_result,
mapping,
);
const changes = {
procedure: fixture.entry,
name: "rea_entry",
comment: "Regular\nUnicode 註記",
inline_comment: "Inline finding",
};
// Prime provider inventories and the session snapshot before edits.
await call("list_names");
const updated = await call("annotate_native_function", changes);
assert.equal(updated.annotations.name, changes.name);
assert.equal(updated.annotations.comment, changes.comment);
assert.equal(updated.annotations.inline_comment, changes.inline_comment);
assert.equal(updated.dossier.procedure.name, changes.name);
assert.ok(updated.dossier.pseudocode.includes(changes.name));
// Exercise the documented recovery through the public MCP catalog on real
// Ghidra, rather than assuming the provider exposes direct comment methods.
const contextCall = async (name, args) => {
const definition = tools.find((tool) => tool.name === name);
assert.ok(definition?.outputSchema, `${name} must be advertised`);
const response = await client.callTool(
{ name, arguments: args },
{ timeout: 240000, toolDefinition: definition },
);
return requireMcpOutput(response, name);
};
const context = (
await contextCall("inspect_address_context", {
address: fixture.entry,
length: fixture.bytes.length,
});
assert.equal(memory.bytes_hex, fixture.bytes.toString("hex"));
assert.equal(memory.complete, true);
const mapping = await call("address_to_file_offset", {
address: fixture.near,
});
assert.equal(mapping.file_offset, 32);
const psp = await call("read_bytes", { address: "0x10000", length: 16 });
assert.equal(psp.returned_bytes, 0);
assert.equal(psp.complete, false);
const tail = await call("read_bytes", { address: "0x1013e", length: 8 });
assert.equal(tail.returned_bytes, 2);
assert.equal(tail.complete, false);
const procedures = await call("list_procedures");
assert.ok(procedures.some((p) => p.address === fixture.entry));
assert.ok(procedures.some((p) => p.address === fixture.near));
const dossier = functionDossierSchema.parse(
})
).result;
assert.equal(context.procedure.state, "available");
assert.equal(context.procedure.value.found, true);
for (const facet of [context.comment, context.inline_comment]) {
assert.equal(facet.state, "unavailable");
assert.match(
facet.remediation,
/analyze_function.*parent_evidence_id.*inspect_analysis_view/u,
);
}
assert.match(context.bookmarks.remediation, /binary_session/u);
const summary = parseEvidence(
await contextCall("analyze_function", {
procedure: context.procedure.value.procedure.address,
}),
).normalized_result;
const comments = parseEvidence(
await contextCall("inspect_analysis_view", {
source: {
kind: "retained-evidence",
evidence_id: summary.parent_evidence_id,
},
view: { kind: "native", facet: "comments" },
}),
).normalized_result;
for (const [kind, text] of [
["comment", changes.comment],
["inline", changes.inline_comment],
])
assert.ok(
comments.item.some(
(item) =>
item.address === context.procedure.value.query_address &&
item.kind === kind &&
item.text === text,
),
);
const outside = (
await contextCall("inspect_address_context", {
address: unlistedAddress,
})
).result;
assert.equal(outside.procedure.state, "available");
assert.equal(outside.procedure.value.found, false);
assert.equal(outside.comment.state, "unavailable");
assert.match(outside.comment.remediation, /binary_session/u);
assert.equal(
await call("procedure_address", { procedure: changes.name }),
fixture.entry,
);
assert.equal(
await call("address_name", { address: fixture.entry }),
changes.name,
);
assert.ok(
(await call("list_procedures")).some((p) => p.value === changes.name),
);
assert.ok((await call("list_names")).some((p) => p.value === changes.name));
assert.equal(
(
await call("analyze_function", {
procedure: fixture.entry,
detail: "complete",
}),
);
assert.ok(dossier.pseudocode.trim().length > 0);
assert.equal(dossier.procedure.body.available, true);
const instruction = await call("inspect_native_instruction", {
address: fixture.entry,
});
assert.equal(
instruction.bytes.replaceAll(" ", "").toLowerCase(),
fixture.entry_hex,
);
// This NOP follows the entry's terminating INT 20h and is not reached by
// either function. It remains valid instruction bytes outside the listing.
const unlistedAddress = "0x1013f";
const unlistedBytes = await call("read_bytes", {
address: unlistedAddress,
length: 1,
});
assert.equal(unlistedBytes.bytes_hex, "90");
const unlistedResponse = await client.callTool({
name: "inspect_native_instruction",
arguments: { address: unlistedAddress },
});
const unlistedEvidence = parseEvidence(
requireMcpOutput(unlistedResponse, "inspect_native_instruction"),
);
const unlisted = unlistedEvidence.normalized_result;
assert.equal(unlisted.status, "not-in-listing");
assert.equal(unlisted.bytes, null);
assert.ok(
unlisted.limitations.some((item) =>
item.includes("did not attempt a decode"),
),
);
const tools = (await client.listTools()).tools;
const advertised = tools.find(
(tool) => tool.name === "inspect_native_instruction",
);
assert.ok(advertised?.outputSchema);
const ajv = new Ajv2020({ strict: false, validateFormats: false });
assert.equal(ajv.validateSchema(advertised.outputSchema), true);
assert.equal(
ajv.validate(advertised.outputSchema, unlistedResponse.structuredContent),
true,
JSON.stringify(ajv.errors),
);
assert.ok(
nativeInstructionSchema.safeParse({ ...unlisted, status: "undecodable" })
.success,
);
const contextDefinition = tools.find(
(tool) => tool.name === "inspect_address_context",
);
assert.ok(contextDefinition?.outputSchema);
const documents = await call("list_documents");
assert.equal(documents.length, 1);
const document = documents[0];
for (const input of [
{ address: fixture.entry, document: `${document}-not-a-program` },
{ address: "not-an-address", document },
]) {
const direct = requireMcpToolError(
await client.callTool({ name: "address_name", arguments: input }),
);
const response = await client.callTool(
{ name: "inspect_address_context", arguments: input },
{ timeout: 240000, toolDefinition: contextDefinition },
);
const context = requireMcpLifecycleResult(
response,
"inspect_address_context",
);
assert.equal(context.name.state, "unavailable");
assert.equal(context.name.error.code, "invalid_request");
assert.deepEqual(
context.name.error.details.issues,
direct.details.issues,
);
assert.equal(context.name.reason, direct.message);
assert.equal(context.name.remediation, direct.remediation.action);
}
assert.equal(
(await call("resolve_native_call_targets", { address: unlistedAddress }))
.status,
"unavailable",
);
assert.deepEqual(
(await cli("inspect-native-instruction", unlistedAddress))
.normalized_result,
unlisted,
);
const cliImage = await cli("inspect-native-load-image");
const stable = (x) => ({
...x,
observations: {
...x.observations,
source_files: x.observations.source_files.map(
({ name: _name, ...file }) => file,
),
},
});
assert.deepEqual(stable(cliImage.normalized_result), stable(image));
assert.deepEqual(
(
await cli("read-bytes", fixture.entry, [
"--length",
String(fixture.bytes.length),
])
).normalized_result,
memory,
);
assert.deepEqual(
(await cli("address-to-file-offset", fixture.near)).normalized_result,
mapping,
);
const changes = {
})
).procedure.name,
changes.name,
);
const rejected = await client.callTool({
name: "annotate_native_function",
arguments: {
procedure: fixture.entry,
name: "rea_entry",
comment: "Regular\nUnicode 註記",
inline_comment: "Inline finding",
};
// Prime provider inventories and the session snapshot before edits.
await call("list_names");
const updated = await call("annotate_native_function", changes);
assert.equal(updated.annotations.name, changes.name);
assert.equal(updated.annotations.comment, changes.comment);
assert.equal(updated.annotations.inline_comment, changes.inline_comment);
assert.equal(updated.dossier.procedure.name, changes.name);
assert.ok(updated.dossier.pseudocode.includes(changes.name));
// Exercise the documented recovery through the public MCP catalog on real
// Ghidra, rather than assuming the provider exposes direct comment methods.
const contextCall = async (name, args) => {
const definition = tools.find((tool) => tool.name === name);
assert.ok(definition?.outputSchema, `${name} must be advertised`);
const response = await client.callTool(
{ name, arguments: args },
{ timeout: 240000, toolDefinition: definition },
);
return requireMcpOutput(response, name);
};
const context = (
await contextCall("inspect_address_context", {
comment: "MUST ROLL BACK",
name: "invalid name\n",
},
});
assert.equal(
rejected.isError,
true,
"Ghidra accepted an invalid function name",
);
const retained = await call("annotate_native_function", {
procedure: fixture.entry,
name: changes.name,
});
assert.equal(
retained.annotations.comment,
changes.comment,
"Failed edit leaked its earlier comment write",
);
assert.equal(retained.annotations.inline_comment, changes.inline_comment);
assert.equal(retained.dossier.procedure.name, changes.name);
await verifyGhidraAddressBoundaries(client, fixture.entry, cli);
const cleared = await call("annotate_native_function", {
procedure: fixture.entry,
comment: "",
inline_comment: "",
});
assert.equal(cleared.annotations.comment, null);
assert.equal(cleared.annotations.inline_comment, null);
assert.ok(!cleared.dossier.comments.some((c) => c.address === fixture.entry));
assert.equal(
(
await call("read_bytes", {
address: fixture.entry,
length: fixture.bytes.length,
})
).result;
assert.equal(context.procedure.state, "available");
assert.equal(context.procedure.value.found, true);
for (const facet of [context.comment, context.inline_comment]) {
assert.equal(facet.state, "unavailable");
assert.match(
facet.remediation,
/analyze_function.*parent_evidence_id.*inspect_analysis_view/u,
);
}
assert.match(context.bookmarks.remediation, /binary_session/u);
const summary = parseEvidence(
await contextCall("analyze_function", {
procedure: context.procedure.value.procedure.address,
}),
).normalized_result;
const comments = parseEvidence(
await contextCall("inspect_analysis_view", {
source: {
kind: "retained-evidence",
evidence_id: summary.parent_evidence_id,
},
view: { kind: "native", facet: "comments" },
}),
).normalized_result;
for (const [kind, text] of [
["comment", changes.comment],
["inline", changes.inline_comment],
])
assert.ok(
comments.item.some(
(item) =>
item.address === context.procedure.value.query_address &&
item.kind === kind &&
item.text === text,
),
);
const outside = (
await contextCall("inspect_address_context", {
address: unlistedAddress,
})
).result;
assert.equal(outside.procedure.state, "available");
assert.equal(outside.procedure.value.found, false);
assert.equal(outside.comment.state, "unavailable");
assert.match(outside.comment.remediation, /binary_session/u);
assert.equal(
await call("procedure_address", { procedure: changes.name }),
fixture.entry,
).bytes_hex,
fixture.bytes.toString("hex"),
);
assert.equal((await call("inspect_native_load_image")).status, "verified");
const cliAnnotated = await cli("annotate-native-function", fixture.entry, [
"--name",
changes.name,
"--comment",
changes.comment,
"--inline-comment",
changes.inline_comment,
]);
assert.deepEqual(cliAnnotated.normalized_result, updated);
if (captureDirectory !== undefined)
await writeFile(
join(captureDirectory, "image.json"),
JSON.stringify(image, null, 2),
{ mode: 0o600 },
);
assert.equal(
await call("address_name", { address: fixture.entry }),
changes.name,
await call("close_binary");
opened = false;
assert.equal(digest(await readFile(targetPath)), sha256);
report = {
ok: true,
mocked: false,
lane: "ghidra-com",
packaged_artifact: packageRoot !== undefined,
host: process.platform,
provider: { id: "ghidra", version: installation.providerVersion },
target: { format: "dos-com", sha256, bytes: fixture.bytes.length },
load_image: {
status: image.status,
checks: image.checks.length,
entry: fixture.entry,
entry_context: image.observations.entry_context,
},
transport: { cli: true, stdio_mcp: true },
memory_evidence: {
complete_file_read: true,
source_offset: 32,
psp_unmapped: true,
partial_read_bytes: 2,
},
functions: { entry: true, near: true, pseudocode_nonempty: true },
annotations: {
atomic_rollback: true,
fresh_inventories: true,
fresh_decompiler: true,
clear_comments: true,
cli_mcp_parity: true,
address_context_comment_recovery: true,
},
context_input_errors_preserved: true,
original_unchanged: true,
address_truncation_rejected: true,
limitations: image.limitations,
};
} catch (error) {
if (stderr.trim()) process.stderr.write(stderr);
throw error;
} finally {
if (opened)
await call("close_binary").catch((error) =>
process.stderr.write(String(error)),
);
assert.ok(
(await call("list_procedures")).some((p) => p.value === changes.name),
);
assert.ok((await call("list_names")).some((p) => p.value === changes.name));
assert.equal(
(
await call("analyze_function", {
procedure: fixture.entry,
detail: "complete",
})
).procedure.name,
changes.name,
);
const rejected = await client.callTool({
name: "annotate_native_function",
arguments: {
procedure: fixture.entry,
comment: "MUST ROLL BACK",
name: "invalid name\n",
},
});
assert.equal(
rejected.isError,
true,
"Ghidra accepted an invalid function name",
);
const retained = await call("annotate_native_function", {
procedure: fixture.entry,
name: changes.name,
});
assert.equal(
retained.annotations.comment,
changes.comment,
"Failed edit leaked its earlier comment write",
);
assert.equal(retained.annotations.inline_comment, changes.inline_comment);
assert.equal(retained.dossier.procedure.name, changes.name);
await verifyGhidraAddressBoundaries(client, fixture.entry, cli);
const cleared = await call("annotate_native_function", {
procedure: fixture.entry,
comment: "",
inline_comment: "",
});
assert.equal(cleared.annotations.comment, null);
assert.equal(cleared.annotations.inline_comment, null);
assert.ok(
!cleared.dossier.comments.some((c) => c.address === fixture.entry),
);
assert.equal(
(
await call("read_bytes", {
address: fixture.entry,
length: fixture.bytes.length,
})
).bytes_hex,
fixture.bytes.toString("hex"),
);
assert.equal((await call("inspect_native_load_image")).status, "verified");
const cliAnnotated = await cli("annotate-native-function", fixture.entry, [
"--name",
changes.name,
"--comment",
changes.comment,
"--inline-comment",
changes.inline_comment,
]);
assert.deepEqual(cliAnnotated.normalized_result, updated);
if (captureDirectory !== undefined)
await writeFile(
join(captureDirectory, "image.json"),
JSON.stringify(image, null, 2),
{ mode: 0o600 },
);
await call("close_binary");
assert.equal(digest(await readFile(targetPath)), sha256);
report = {
ok: true,
mocked: false,
lane: "ghidra-com",
packaged_artifact: packageRoot !== undefined,
host: process.platform,
provider: { id: "ghidra", version: installation.providerVersion },
target: { format: "dos-com", sha256, bytes: fixture.bytes.length },
load_image: {
status: image.status,
checks: image.checks.length,
entry: fixture.entry,
entry_context: image.observations.entry_context,
},
transport: { cli: true, stdio_mcp: true },
memory_evidence: {
complete_file_read: true,
source_offset: 32,
psp_unmapped: true,
partial_read_bytes: 2,
},
functions: { entry: true, near: true, pseudocode_nonempty: true },
annotations: {
atomic_rollback: true,
fresh_inventories: true,
fresh_decompiler: true,
clear_comments: true,
cli_mcp_parity: true,
address_context_comment_recovery: true,
},
context_input_errors_preserved: true,
original_unchanged: true,
address_truncation_rejected: true,
limitations: image.limitations,
};
} catch (error) {
if (stderr.trim()) process.stderr.write(stderr);
throw error;
}
}, [
async () => {
await closeVerifierResources([
async () => {
if (connected) await call("close_binary", {});
},
() => client.close(),
() => transport.close(),
]);
assert.deepEqual(
(await readdir(runtime)).filter((name) => name.startsWith("rea-ghidra-")),
[],
"Owned Ghidra runtime survived close",
);
verifierRun = await completeVerifierRun(run);
assert.equal(verifierRun.process_lineage.status, "verified");
assert.deepEqual(verifierRun.process_lineage.descendants, []);
await rm(workspace, { recursive: true, force: true });
},
]);
await client.close();
await transport.close();
await rm(workspace, { recursive: true, force: true });
}
assert.ok(report);
verifierRun = await completeVerifierRun(run);
const verifierRun = await completeVerifierRun(run);
assert.equal(verifierRun.process_lineage.status, "verified");
assert.deepEqual(verifierRun.process_lineage.descendants, []);
process.stdout.write(
+3 -6
View File
@@ -115,12 +115,9 @@ async function runPass({ fixture, languageOverride, label, layout }) {
stderr = (stderr + chunk.toString()).slice(-65536);
});
const call = async (name, args = {}) => {
const reply = await client.callTool(
{ name, arguments: args },
{
timeout: 180000,
},
);
const reply = await client.callTool({ name, arguments: args }, undefined, {
timeout: 180000,
});
return requireMcpOperationResult(reply, name);
};
const report = { label, language_override: languageOverride ?? null };
+399 -416
View File
@@ -1,8 +1,4 @@
#!/usr/bin/env node
import {
closeVerifierResources,
withVerifierCleanup,
} from "./lib/verifier-cleanup.mjs";
import assert from "node:assert/strict";
import {
requireMcpOperationResult,
@@ -103,440 +99,427 @@ const transport = new StdioClientTransport({
stderr: "pipe",
});
const client = new Client({ name: "rea-real-dos-proof", version: "1" });
let opened = false;
let stderr = "";
let connected = false;
let verifierRun;
let report;
await withVerifierCleanup(async () => {
try {
await client.connect(transport);
connected = true;
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
const target = await call("open_binary", {
path: targetPath,
provider_id: "ghidra",
});
assert.equal(target.format, "dos-mz");
assert.equal(target.kind, "executable");
assert.equal(target.architecture, "x86");
assert.equal(target.sha256, sha256);
const image = await call("inspect_native_load_image");
assert.equal(
image.status,
"verified",
JSON.stringify(image.checks?.filter((check) => !check.matched)),
);
assert.equal(image.target_sha256, sha256);
assert.equal(image.header_bytes, fixture.header_bytes);
assert.equal(image.module_bytes, fixture.module_bytes);
assert.equal(image.entry.linear_address, expected.entry);
assert.ok(image.checks.every((check) => check.matched));
assert.ok(image.observations.relocations.length > 0);
assert.ok(
image.observations.relocations.every(
({ symbol }) => symbol === null || typeof symbol === "string",
try {
await client.connect(transport);
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
const target = await call("open_binary", {
path: targetPath,
provider_id: "ghidra",
});
opened = true;
assert.equal(target.format, "dos-mz");
assert.equal(target.kind, "executable");
assert.equal(target.architecture, "x86");
assert.equal(target.sha256, sha256);
const image = await call("inspect_native_load_image");
assert.equal(
image.status,
"verified",
JSON.stringify(image.checks?.filter((check) => !check.matched)),
);
assert.equal(image.target_sha256, sha256);
assert.equal(image.header_bytes, fixture.header_bytes);
assert.equal(image.module_bytes, fixture.module_bytes);
assert.equal(image.entry.linear_address, expected.entry);
assert.ok(image.checks.every((check) => check.matched));
assert.ok(image.observations.relocations.length > 0);
assert.ok(
image.observations.relocations.every(
({ symbol }) => symbol === null || typeof symbol === "string",
),
"Each real Ghidra relocation must record its symbol observation",
);
const cliImage = await cliEvidence("inspect-native-load-image");
// Snapshot filenames are session-specific. Every measured byte/range/check is stable.
const stableImage = (value) => ({
...value,
observations: {
...value.observations,
source_files: value.observations.source_files.map(
({ name: _name, ...file }) => file,
),
"Each real Ghidra relocation must record its symbol observation",
);
const cliImage = await cliEvidence("inspect-native-load-image");
// Snapshot filenames are session-specific. Every measured byte/range/check is stable.
const stableImage = (value) => ({
...value,
observations: {
...value.observations,
source_files: value.observations.source_files.map(
({ name: _name, ...file }) => file,
),
},
});
assert.deepEqual(
stableImage(cliImage.normalized_result),
stableImage(image),
);
const mapped = await call("address_to_file_offset", {
address: expected.entry,
});
assert.equal(mapped.file_offset, fixture.header_bytes);
const headerMapping = await call("address_to_file_offset", {
address: "HEADER:0x0",
});
assert.equal(headerMapping.file_offset, 0);
const fileRelocation = await call("address_to_file_offset", {
address: address(fixture.relocation_offset),
});
assert.equal(
fileRelocation.file_offset,
fixture.header_bytes + fixture.relocation_offset,
);
const memoryRelocation = await call("read_bytes", {
address: address(fixture.relocation_offset),
length: 2,
});
assert.equal(memoryRelocation.bytes_hex, "0410");
assert.equal(memoryRelocation.complete, true);
assert.deepEqual(
(
await cliEvidence("read-bytes", address(fixture.relocation_offset), [
"--length",
"2",
])
).normalized_result,
memoryRelocation,
);
assert.deepEqual(
(await cliEvidence("address-to-file-offset", expected.entry))
.normalized_result,
mapped,
);
const tail = await call("read_bytes", {
address: address(fixture.module_bytes - 2),
length: 8,
});
assert.equal(tail.returned_bytes, 2);
assert.equal(tail.complete, false);
const unmapped = await call("read_bytes", {
address: "0x80000",
length: 8,
});
assert.equal(unmapped.returned_bytes, 0);
assert.equal(unmapped.complete, false);
const noSource = await client.callTool({
name: "address_to_file_offset",
arguments: { address: "0x80000" },
});
assert.equal(noSource.isError, true);
const procedures = await call("list_procedures");
assert.ok(
Array.isArray(procedures),
"Public procedure inventory is not an array",
);
const identities = Object.fromEntries(
Object.entries(expected).map(([key, value]) => {
const item = procedures.find(
(candidate) => candidate.address === value,
);
assert.ok(
item,
`Source-owned ${key} procedure ${value} was not discovered from MZ entry/call flow`,
);
return [key, item];
}),
);
const session = await call("binary_session");
const profile = session.analysis_provider_binding?.analysis_profile;
assert.ok(profile, "Public session omitted committed profile");
assertProfile(profile);
assert.equal(session.sha256, sha256);
assert.equal(session.open, true);
const facts = {};
for (const [key, identity] of Object.entries(identities)) {
const procedure = identity.address; // Use returned addresses, never symbol-name heuristics.
assert.match(procedure, /^0x[0-9a-f]+$/u);
const resolved = await call("procedure_address", { procedure });
assert.equal(resolved, procedure, "Flat address round-trip drifted");
const info = await call("procedure_info", { procedure });
const dossierResult = await evidenceCall("analyze_function", {
procedure,
detail: "complete",
});
const dossier = functionDossierSchema.parse(
dossierResult.normalized_result,
},
});
assert.deepEqual(stableImage(cliImage.normalized_result), stableImage(image));
const mapped = await call("address_to_file_offset", {
address: expected.entry,
});
assert.equal(mapped.file_offset, fixture.header_bytes);
const headerMapping = await call("address_to_file_offset", {
address: "HEADER:0x0",
});
assert.equal(headerMapping.file_offset, 0);
const fileRelocation = await call("address_to_file_offset", {
address: address(fixture.relocation_offset),
});
assert.equal(
fileRelocation.file_offset,
fixture.header_bytes + fixture.relocation_offset,
);
const memoryRelocation = await call("read_bytes", {
address: address(fixture.relocation_offset),
length: 2,
});
assert.equal(memoryRelocation.bytes_hex, "0410");
assert.equal(memoryRelocation.complete, true);
assert.deepEqual(
(
await cliEvidence("read-bytes", address(fixture.relocation_offset), [
"--length",
"2",
])
).normalized_result,
memoryRelocation,
);
assert.deepEqual(
(await cliEvidence("address-to-file-offset", expected.entry))
.normalized_result,
mapped,
);
const tail = await call("read_bytes", {
address: address(fixture.module_bytes - 2),
length: 8,
});
assert.equal(tail.returned_bytes, 2);
assert.equal(tail.complete, false);
const unmapped = await call("read_bytes", { address: "0x80000", length: 8 });
assert.equal(unmapped.returned_bytes, 0);
assert.equal(unmapped.complete, false);
const noSource = await client.callTool({
name: "address_to_file_offset",
arguments: { address: "0x80000" },
});
assert.equal(noSource.isError, true);
const procedures = await call("list_procedures");
assert.ok(
Array.isArray(procedures),
"Public procedure inventory is not an array",
);
const identities = Object.fromEntries(
Object.entries(expected).map(([key, value]) => {
const item = procedures.find((candidate) => candidate.address === value);
assert.ok(
item,
`Source-owned ${key} procedure ${value} was not discovered from MZ entry/call flow`,
);
assertBody(info.body, procedure);
assertBody(dossier.procedure.body, procedure);
assert.equal(info.entrypoint, procedure);
assert.equal(info.length, info.body.total_bytes);
assert.deepEqual(dossier.procedure.body, info.body);
const containingQueries = [procedure, info.body.ranges.at(-1).end];
for (const query of containingQueries) {
const containing = await call("resolve_containing_procedure", {
address: query,
});
assert.equal(containing.found, true);
assert.equal(containing.query_address, query);
assert.equal(containing.procedure.address, procedure);
assert.deepEqual(containing.procedure.body, info.body);
}
if (key === "entry") {
const gap = await call("resolve_containing_procedure", {
address: address(0x10),
});
assert.equal(
gap.found,
false,
"Gap inside enclosing span was treated as function body",
);
assert.equal(gap.reason, "not_in_procedure");
}
return [key, item];
}),
);
const session = await call("binary_session");
const profile = session.analysis_provider_binding?.analysis_profile;
assert.ok(profile, "Public session omitted committed profile");
assertProfile(profile);
assert.equal(session.sha256, sha256);
assert.equal(session.open, true);
const facts = {};
for (const [key, identity] of Object.entries(identities)) {
const procedure = identity.address; // Use returned addresses, never symbol-name heuristics.
assert.match(procedure, /^0x[0-9a-f]+$/u);
const resolved = await call("procedure_address", { procedure });
assert.equal(resolved, procedure, "Flat address round-trip drifted");
const info = await call("procedure_info", { procedure });
const dossierResult = await evidenceCall("analyze_function", {
procedure,
detail: "complete",
});
const dossier = functionDossierSchema.parse(
dossierResult.normalized_result,
);
assertBody(info.body, procedure);
assertBody(dossier.procedure.body, procedure);
assert.equal(info.entrypoint, procedure);
assert.equal(info.length, info.body.total_bytes);
assert.deepEqual(dossier.procedure.body, info.body);
const containingQueries = [procedure, info.body.ranges.at(-1).end];
for (const query of containingQueries) {
const containing = await call("resolve_containing_procedure", {
address: query,
});
assert.equal(containing.found, true);
assert.equal(containing.query_address, query);
assert.equal(containing.procedure.address, procedure);
assert.deepEqual(containing.procedure.body, info.body);
}
if (key === "entry") {
const gap = await call("resolve_containing_procedure", {
address: address(0x10),
});
assert.equal(
gap.found,
false,
"Gap inside enclosing span was treated as function body",
);
assert.equal(gap.reason, "not_in_procedure");
}
assert.ok(
dossier.pseudocode.trim().length > 0,
"Real DOS decompilation returned empty pseudocode",
);
assert.equal(dossier.procedure.address, procedure);
const window = await call("read_function_instructions", { procedure });
assert.deepEqual(window.procedure.body, info.body);
assert.ok(window.instructions.length > 0);
if (key === "entry") {
assert.deepEqual(info.body.ranges, [
{ start: procedure, end: address(fixture.entry_first_bytes - 1) },
{
start: address(fixture.entry_tail_offset),
end: address(
fixture.entry_tail_offset + fixture.entry_tail_bytes - 1,
),
},
]);
assert.equal(
info.body.total_bytes,
fixture.entry_first_bytes + fixture.entry_tail_bytes,
);
assert.equal(
info.body.span_bytes,
fixture.entry_tail_offset + fixture.entry_tail_bytes,
);
assert.equal(info.body.non_contiguous, true);
} else {
const size =
key === "near" ? fixture.near_body_bytes : fixture.far_body_bytes;
assert.deepEqual(info.body.ranges, [
{
start: procedure,
end: address(
(key === "near" ? fixture.near_offset : fixture.far_offset) +
size -
1,
),
},
]);
assert.equal(info.body.total_bytes, size);
assert.equal(info.body.span_bytes, size);
}
const cli = await cliEvidence("function", procedure);
if (captureDirectory !== undefined) {
await writeFile(
join(captureDirectory, `public-fixture-${key}-cli.json`),
`${JSON.stringify(cli, null, 2)}\n`,
{ mode: 0o600 },
);
await writeFile(
join(captureDirectory, `public-fixture-${key}-mcp.json`),
`${JSON.stringify(dossierResult, null, 2)}\n`,
{ mode: 0o600 },
);
}
const cliDossier = functionDossierSchema.parse(cli.normalized_result);
assert.deepEqual(
stableDossierObservations(cliDossier),
stableDossierObservations(dossier),
"CLI/MCP stable DOS dossier observations differ",
);
facts[key] = {
address: procedure,
body: info.body,
instruction_count: window.instructions.length,
pseudocode_nonempty: true,
pseudocode: dossier.pseudocode,
cli_mcp_observations_equal: true,
containing_procedure_body_equal: true,
limitations: dossier.limitations,
};
}
const move = await call("inspect_native_instruction", {
address: identities.entry.address,
});
assert.equal(move.status, "decoded");
assert.equal(move.length, 3);
assert.equal(hexBytes(move.bytes), "b83412");
assert.match(move.mnemonic, /^MOV$/iu);
assert.ok(
move.operands
.flatMap((operand) => operand.components)
.some(
(part) =>
part.kind === "register" &&
/^AX$/iu.test(part.text) &&
part.bit_width === 16,
),
"DOS entry was not decoded as 16-bit AX",
dossier.pseudocode.trim().length > 0,
"Real DOS decompilation returned empty pseudocode",
);
const nearMemory = await call("inspect_native_instruction", {
address: identities.near.address,
});
assert.equal(nearMemory.status, "decoded");
assert.equal(nearMemory.length, 2);
assert.equal(hexBytes(nearMemory.bytes), "fe00");
assert.match(nearMemory.mnemonic, /^INC$/iu);
const nearCall = await call("inspect_native_instruction", {
address: address(fixture.near_call_offset),
});
const farCall = await call("inspect_native_instruction", {
address: address(fixture.far_call_offset),
});
assert.equal(hexBytes(nearCall.bytes), "e81a00");
// Source far segment 0004 is relocated by the independently fixed 1000 load segment.
assert.equal(hexBytes(farCall.bytes), "9a00000410");
for (const [key, instruction] of [
["near", nearCall],
["far", farCall],
]) {
assert.equal(instruction.flow.kind, "call");
assert.ok(
instruction.flow.direct_destinations.includes(identities[key].address),
assert.equal(dossier.procedure.address, procedure);
const window = await call("read_function_instructions", { procedure });
assert.deepEqual(window.procedure.body, info.body);
assert.ok(window.instructions.length > 0);
if (key === "entry") {
assert.deepEqual(info.body.ranges, [
{ start: procedure, end: address(fixture.entry_first_bytes - 1) },
{
start: address(fixture.entry_tail_offset),
end: address(
fixture.entry_tail_offset + fixture.entry_tail_bytes - 1,
),
},
]);
assert.equal(
info.body.total_bytes,
fixture.entry_first_bytes + fixture.entry_tail_bytes,
);
const targets = await call("resolve_native_call_targets", {
address: instruction.address,
});
assert.equal(targets.status, "direct");
assert.ok(
targets.targets.some(
(target) => target.address === identities[key].address,
),
assert.equal(
info.body.span_bytes,
fixture.entry_tail_offset + fixture.entry_tail_bytes,
);
assert.equal(info.body.non_contiguous, true);
} else {
const size =
key === "near" ? fixture.near_body_bytes : fixture.far_body_bytes;
assert.deepEqual(info.body.ranges, [
{
start: procedure,
end: address(
(key === "near" ? fixture.near_offset : fixture.far_offset) +
size -
1,
),
},
]);
assert.equal(info.body.total_bytes, size);
assert.equal(info.body.span_bytes, size);
}
const cli = await cliEvidence("function", procedure);
if (captureDirectory !== undefined) {
await writeFile(
join(captureDirectory, `public-fixture-${key}-cli.json`),
`${JSON.stringify(cli, null, 2)}\n`,
{ mode: 0o600 },
);
await writeFile(
join(captureDirectory, `public-fixture-${key}-mcp.json`),
`${JSON.stringify(dossierResult, null, 2)}\n`,
{ mode: 0o600 },
);
}
const invalid = await client.callTool({
name: "procedure_info",
arguments: { procedure: "0xffffffffffffffff" },
});
assert.equal(invalid.isError, true, "Unmapped address did not fail");
// The CLI reads a fresh session, so compare it with MCP before the boundary
// helper's temporary rename and restoration.
const entryWindow = await call("read_function_instructions", {
procedure: identities.entry.address,
});
const cliWindow = await cliEvidence(
"instructions",
identities.entry.address,
);
assert.deepEqual(cliWindow.normalized_result, entryWindow);
await verifyGhidraAddressBoundaries(
client,
identities.entry.address,
cliEvidence,
{ mutable: !windows },
);
const entrySymbol = entryWindow.procedure.symbol;
assert.ok(entrySymbol != null, "The DOS entry needs observed symbol facts");
// Restoring the name is itself a user edit, so Ghidra records user provenance.
// Windows P0 refuses edits and never renames, so its entry stays unchanged.
const cliDossier = functionDossierSchema.parse(cli.normalized_result);
assert.deepEqual(
await call("read_function_instructions", {
procedure: identities.entry.address,
}),
windows
? entryWindow
: {
...entryWindow,
procedure: {
...entryWindow.procedure,
symbol: { ...entrySymbol, source: "user_defined" },
},
},
"Restoring the entry name changed more than its symbol provenance",
stableDossierObservations(cliDossier),
stableDossierObservations(dossier),
"CLI/MCP stable DOS dossier observations differ",
);
assert.equal(
digest(await readFile(targetPath)),
sha256,
"Analysis changed original bytes",
);
await call("close_binary");
assert.equal((await call("binary_session")).open, false);
report = {
ok: true,
mocked: false,
lane: "ghidra-dos",
host: process.platform,
provider: { id: "ghidra", version: installation.providerVersion },
target: {
format: "dos-mz",
architecture: "x86",
sha256,
bytes: fixture.bytes.length,
},
profile: {
digest: profile.digest,
loader: profile.parameters.loader,
language_id: profile.parameters.language_id,
compiler_spec_id: profile.parameters.compiler_spec_id,
load_segment: profile.parameters.load_segment,
},
transport: { cli: true, stdio_mcp: true },
load_image: {
status: image.status,
checks: image.checks.length,
mappings: image.observations.mappings.length,
relocations: image.observations.relocations.length,
cli_mcp_parity: true,
},
memory_evidence: {
relocated_word_hex: memoryRelocation.bytes_hex,
entry_file_offset: mapped.file_offset,
header_file_offset: headerMapping.file_offset,
partial_read_bytes: tail.returned_bytes,
unmapped_read_bytes: unmapped.returned_bytes,
unmapped_file_offset_rejected: true,
cli_mcp_parity: true,
},
functions: facts,
instruction_oracles: {
entry_bytes: hexBytes(move.bytes),
near_memory_bytes: hexBytes(nearMemory.bytes),
near_call_bytes: hexBytes(nearCall.bytes),
relocated_far_call_bytes: hexBytes(farCall.bytes),
near_target: expected.near,
far_target: expected.far,
},
invalid_address_rejected: true,
address_truncation_rejected: true,
original_unchanged: true,
limitations: [
"Read-only import/decompilation evidence, not DOS runtime execution or whole-program semantic equivalence.",
"The complete body is Ghidra's observed AddressSet, not independently proven historical compiler ownership.",
"Language/compiler identity is bound by the production authenticated bridge handshake; profile loader is the declared import policy.",
"Native high-pcode flow is excluded from cross-runtime parity: LOAD/STORE address-space selector constants are process-specific in current Ghidra output. Both dossiers are still strictly parsed.",
...(windows
? [
"Windows P0 is read-only: annotation and naming edits were checked as refused, not exercised.",
]
: []),
],
facts[key] = {
address: procedure,
body: info.body,
instruction_count: window.instructions.length,
pseudocode_nonempty: true,
pseudocode: dossier.pseudocode,
cli_mcp_observations_equal: true,
containing_procedure_body_equal: true,
limitations: dossier.limitations,
};
} catch (error) {
if (stderr.trim())
process.stderr.write(`DOS verifier MCP diagnostics:\n${stderr}\n`);
throw error;
}
}, [
async () => {
await closeVerifierResources([
async () => {
if (connected) await call("close_binary", {});
},
() => client.close(),
() => transport.close(),
]);
const move = await call("inspect_native_instruction", {
address: identities.entry.address,
});
assert.equal(move.status, "decoded");
assert.equal(move.length, 3);
assert.equal(hexBytes(move.bytes), "b83412");
assert.match(move.mnemonic, /^MOV$/iu);
assert.ok(
move.operands
.flatMap((operand) => operand.components)
.some(
(part) =>
part.kind === "register" &&
/^AX$/iu.test(part.text) &&
part.bit_width === 16,
),
"DOS entry was not decoded as 16-bit AX",
);
const nearMemory = await call("inspect_native_instruction", {
address: identities.near.address,
});
assert.equal(nearMemory.status, "decoded");
assert.equal(nearMemory.length, 2);
assert.equal(hexBytes(nearMemory.bytes), "fe00");
assert.match(nearMemory.mnemonic, /^INC$/iu);
const nearCall = await call("inspect_native_instruction", {
address: address(fixture.near_call_offset),
});
const farCall = await call("inspect_native_instruction", {
address: address(fixture.far_call_offset),
});
assert.equal(hexBytes(nearCall.bytes), "e81a00");
// Source far segment 0004 is relocated by the independently fixed 1000 load segment.
assert.equal(hexBytes(farCall.bytes), "9a00000410");
for (const [key, instruction] of [
["near", nearCall],
["far", farCall],
]) {
assert.equal(instruction.flow.kind, "call");
assert.ok(
instruction.flow.direct_destinations.includes(identities[key].address),
);
const targets = await call("resolve_native_call_targets", {
address: instruction.address,
});
assert.equal(targets.status, "direct");
assert.ok(
targets.targets.some(
(target) => target.address === identities[key].address,
),
);
}
const invalid = await client.callTool({
name: "procedure_info",
arguments: { procedure: "0xffffffffffffffff" },
});
assert.equal(invalid.isError, true, "Unmapped address did not fail");
// The CLI reads a fresh session, so compare it with MCP before the boundary
// helper's temporary rename and restoration.
const entryWindow = await call("read_function_instructions", {
procedure: identities.entry.address,
});
const cliWindow = await cliEvidence("instructions", identities.entry.address);
assert.deepEqual(cliWindow.normalized_result, entryWindow);
await verifyGhidraAddressBoundaries(
client,
identities.entry.address,
cliEvidence,
{ mutable: !windows },
);
const entrySymbol = entryWindow.procedure.symbol;
assert.ok(entrySymbol != null, "The DOS entry needs observed symbol facts");
// Restoring the name is itself a user edit, so Ghidra records user provenance.
// Windows P0 refuses edits and never renames, so its entry stays unchanged.
assert.deepEqual(
await call("read_function_instructions", {
procedure: identities.entry.address,
}),
windows
? entryWindow
: {
...entryWindow,
procedure: {
...entryWindow.procedure,
symbol: { ...entrySymbol, source: "user_defined" },
},
},
"Restoring the entry name changed more than its symbol provenance",
);
assert.equal(
digest(await readFile(targetPath)),
sha256,
"Analysis changed original bytes",
);
await call("close_binary");
opened = false;
assert.equal((await call("binary_session")).open, false);
report = {
ok: true,
mocked: false,
lane: "ghidra-dos",
host: process.platform,
provider: { id: "ghidra", version: installation.providerVersion },
target: {
format: "dos-mz",
architecture: "x86",
sha256,
bytes: fixture.bytes.length,
},
profile: {
digest: profile.digest,
loader: profile.parameters.loader,
language_id: profile.parameters.language_id,
compiler_spec_id: profile.parameters.compiler_spec_id,
load_segment: profile.parameters.load_segment,
},
transport: { cli: true, stdio_mcp: true },
load_image: {
status: image.status,
checks: image.checks.length,
mappings: image.observations.mappings.length,
relocations: image.observations.relocations.length,
cli_mcp_parity: true,
},
memory_evidence: {
relocated_word_hex: memoryRelocation.bytes_hex,
entry_file_offset: mapped.file_offset,
header_file_offset: headerMapping.file_offset,
partial_read_bytes: tail.returned_bytes,
unmapped_read_bytes: unmapped.returned_bytes,
unmapped_file_offset_rejected: true,
cli_mcp_parity: true,
},
functions: facts,
instruction_oracles: {
entry_bytes: hexBytes(move.bytes),
near_memory_bytes: hexBytes(nearMemory.bytes),
near_call_bytes: hexBytes(nearCall.bytes),
relocated_far_call_bytes: hexBytes(farCall.bytes),
near_target: expected.near,
far_target: expected.far,
},
invalid_address_rejected: true,
address_truncation_rejected: true,
original_unchanged: true,
limitations: [
"Read-only import/decompilation evidence, not DOS runtime execution or whole-program semantic equivalence.",
"The complete body is Ghidra's observed AddressSet, not independently proven historical compiler ownership.",
"Language/compiler identity is bound by the production authenticated bridge handshake; profile loader is the declared import policy.",
"Native high-pcode flow is excluded from cross-runtime parity: LOAD/STORE address-space selector constants are process-specific in current Ghidra output. Both dossiers are still strictly parsed.",
...(windows
? [
"Windows P0 is read-only: annotation and naming edits were checked as refused, not exercised.",
]
: []),
],
};
} catch (error) {
if (stderr.trim())
process.stderr.write(`DOS verifier MCP diagnostics:\n${stderr}\n`);
throw error;
} finally {
try {
if (opened) {
try {
await call("close_binary");
} catch (error) {
process.stderr.write(`DOS cleanup close failed: ${String(error)}\n`);
}
}
try {
await client.close();
} finally {
await transport.close();
}
assert.equal(
digest(await readFile(targetPath)),
sha256,
"Original changed during cleanup",
);
assert.deepEqual(
(await readdir(runtime)).filter((name) => name.startsWith("rea-ghidra-")),
[],
"Owned Ghidra runtime survived close",
const leftovers = (await readdir(runtime)).filter((name) =>
name.startsWith("rea-ghidra-"),
);
verifierRun = await completeVerifierRun(run);
if (process.platform !== "win32") {
assert.equal(verifierRun.process_lineage.status, "verified");
assert.deepEqual(verifierRun.process_lineage.descendants, []);
}
assert.deepEqual(leftovers, [], "Owned Ghidra runtime survived close");
} finally {
await rm(workspace, { recursive: true, force: true });
},
]);
}
}
assert.ok(report, "No DOS proof report was produced");
verifierRun = await completeVerifierRun(run);
const verifierRun = await completeVerifierRun(run);
// Windows provider jobs are verified by their native owners, as in the 6502 lane.
if (!windows) assertVerifierCleanupComplete(verifierRun.process_lineage);
process.stdout.write(
+1
View File
@@ -284,6 +284,7 @@ try {
const call = async (name, args = {}) => {
const reply = await client.callTool(
{ name, arguments: args },
undefined,
{ timeout: 180000 },
);
return requireMcpOperationResult(reply, name);
+231 -257
View File
@@ -1,8 +1,4 @@
#!/usr/bin/env node
import {
closeVerifierResources,
withVerifierCleanup,
} from "./lib/verifier-cleanup.mjs";
import assert from "node:assert/strict";
import {
requireMcpLifecycleResult,
@@ -28,8 +24,6 @@ import { parseEvidence } from "../dist/domain/evidence.js";
import { nativeDataTypeSchema } from "../dist/domain/native/nativeDataType.js";
import { nativeLoadImageSchema } from "../dist/domain/native/nativeLoadImage.js";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
const run = createVerifierRun();
const mode = process.argv[2] ?? "symbols";
if (
![
@@ -158,268 +152,248 @@ transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
let opened = false;
let connected = false;
let verifierRun;
let report;
await withVerifierCleanup(async () => {
try {
if (mode === "loader-failure") await prepareLoaderFailure();
await client.connect(transport);
connected = true;
const response = await client.callTool(
{
name: "open_binary",
arguments: { path: target, provider_id: "ghidra" },
},
{ timeout: 360000 },
);
if (
["unsupported", "malformed", "ambiguous", "loader-failure"].includes(mode)
) {
// MCP open binds a lazy session; the first inspection starts import.
let failure = response;
if (failure.isError !== true) {
opened = true;
failure = await client.callTool(
{ name: "inspect_native_load_image", arguments: {} },
{ timeout: 360000 },
);
}
assert.equal(failure.isError, true, JSON.stringify(failure));
const diagnostic = JSON.stringify(failure);
assert.match(
diagnostic,
mode === "unsupported"
? /Unsupported NativeAOT RTR format/
: mode === "malformed"
? /Reversed NativeAOT section/
: mode === "loader-failure"
? /ExceptionInInitializerError.*REA_INITIALIZER_FIXTURE/
: /Ambiguous NativeAOT directory candidates/,
);
report = { mode, target, sha256, typed_failure: failure };
} else if (mode === "default-native") {
assert.notEqual(response.isError, true, JSON.stringify(response));
try {
if (mode === "loader-failure") await prepareLoaderFailure();
await client.connect(transport);
const response = await client.callTool(
{ name: "open_binary", arguments: { path: target, provider_id: "ghidra" } },
{ timeout: 360000 },
);
if (
["unsupported", "malformed", "ambiguous", "loader-failure"].includes(mode)
) {
// MCP open binds a lazy session; the first inspection starts import.
let failure = response;
if (failure.isError !== true) {
opened = true;
const image = nativeLoadImageSchema.parse(
await call("inspect_native_load_image"),
failure = await client.callTool(
{ name: "inspect_native_load_image", arguments: {} },
{ timeout: 360000 },
);
assert.equal(image.observations.metadata_recovery, undefined);
report = { mode, target, sha256, image };
} else {
assert.notEqual(response.isError, true, JSON.stringify(response));
opened = true;
const image = nativeLoadImageSchema.parse(
await call("inspect_native_load_image"),
);
const summary = image.observations.metadata_recovery?.[0];
assert.ok(
summary,
"Loaded-image inspection omitted inline runtime-format discovery",
);
assert.equal(summary.format, "dotnet-nativeaot");
assert.equal(
summary.analysis_artifact_sha256,
hash(await readFile(env.REA_GHIDRA_NATIVEAOT_JAR)),
);
if (mode === "ordinary") {
assert.equal(summary.status, "not_applicable");
assert.equal(summary.method_tables, 0);
assert.deepEqual(summary.types, []);
report = { mode, target, sha256, summary };
} else {
assert.ok(["complete", "partial"].includes(summary.status));
assert.equal(summary.format_major, 9);
assert.equal(summary.format_minor, 1);
assert.equal(summary.types.length, summary.method_tables);
assert.ok(summary.method_tables > 0);
assert.equal(
summary.discovery,
mode === "stripped" || oracle.runtime === "win-x64"
? "signature-heuristic"
: "symbol",
);
assert.equal(summary.derived_memory.file_offset, null);
assert.ok(summary.coverage.frozen_objects_annotated > 0);
// Independent compiler/linker symbols identify fixture classes. Recovered
// Class_address names are never treated as the original source identities.
const symbols = await readFile(
join(fixtureRoot, "symbols.txt"),
"utf8",
);
const bias =
oracle.runtime === "linux-x64"
? BigInt(image.observations.image_base)
: 0n;
const addressOf = (name) => {
const line = symbols
.split(/\r?\n/u)
.find((line) => line.split(/\s+/u).includes(name));
assert.ok(line, `Independent symbol missing: ${name}`);
const match =
oracle.runtime === "linux-x64"
? line.match(/^([a-f0-9]+)\s/iu)
: line.match(/\s([a-f0-9]{16})\s/iu);
assert.ok(match, `Unrecognized independent symbol line: ${line}`);
return `0x${(BigInt(`0x${match[1]}`) + bias).toString(16)}`;
};
const fixtureSymbols =
oracle.symbols ??
(oracle.runtime === "win-x64"
? {
base: "??_7NativeAotFixture_BaseProbe@@6B@",
derived: "??_7NativeAotFixture_DerivedProbe@@6B@",
interface: "??_7NativeAotFixture_IProbe@@6B@",
compute: "NativeAotFixture_DerivedProbe__Compute",
dispatch: "NativeAotFixture_Program__Dispatch",
}
: {
base: "_ZTV26NativeAotFixture_BaseProbe",
derived: "_ZTV29NativeAotFixture_DerivedProbe",
interface: "_ZTV23NativeAotFixture_IProbe",
compute: "NativeAotFixture_DerivedProbe__Compute",
dispatch: "NativeAotFixture_Program__Dispatch",
});
const lookup = (address) => {
const identity = summary.types.find(
(type) => type.address === address,
);
assert.ok(identity?.type, `Recovered identity missing at ${address}`);
return identity.type;
};
const derivedAddress = addressOf(fixtureSymbols.derived);
const baseAddress = addressOf(fixtureSymbols.base);
const interfaceAddress = addressOf(fixtureSymbols.interface);
const derived = nativeDataTypeSchema.parse(
await call("inspect_native_data_type", {
type: lookup(derivedAddress),
}),
);
const metadata = derived.metadata_recovery;
assert.ok(metadata);
assert.equal(metadata.original_name, null);
assert.equal(metadata.name_origin, "generated");
assert.equal(metadata.method_table_address, derivedAddress);
assert.equal(metadata.related_type.address, baseAddress);
assert.ok(
metadata.interfaces.some((type) => type.address === interfaceAddress),
);
assert.ok(
metadata.virtual_slots.some(
(slot) => slot.target_address === addressOf(fixtureSymbols.compute),
),
);
const compute = await call("analyze_function", {
procedure: addressOf(fixtureSymbols.compute),
detail: "complete",
});
assert.ok(compute.pseudocode.trim());
assert.doesNotMatch(
compute.pseudocode,
/\b__thiscall\b/u,
"Upstream x64 calling-convention rewrite leaked into recovered pseudocode",
);
assert.match(compute.pseudocode, /(?:0xb|\b11\b|<<)/u);
const dispatch = await call("analyze_function", {
procedure: addressOf(fixtureSymbols.dispatch),
detail: "complete",
});
assert.ok(dispatch.pseudocode.trim());
for (const literal of oracle.expected_strings)
assert.ok(
(await call("search_strings", { pattern: literal })).length > 0,
`Frozen string missing: ${literal}`,
);
const observed = await call("read_bytes", {
address: metadata.virtual_slots[0].slot_address,
length: 8,
});
assert.equal(observed.complete, true);
assert.equal(
`0x${Buffer.from(observed.bytes_hex, "hex").readBigUInt64LE().toString(16)}`,
metadata.virtual_slots[0].target_address,
);
report = { mode, target, sha256, summary, derived, compute, dispatch };
await call("close_binary");
opened = false;
// One representative CLI boundary, selected explicitly to avoid repeated
// full NativeAOT auto-analysis during each focused iteration.
if (process.env.REA_NATIVEAOT_PROOF_CLI === "1") {
const { stdout } = await promisify(execFile)(
process.execPath,
[
entrypoint,
"inspect-native-data-type",
target,
"--type",
lookup(derivedAddress),
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 360000, maxBuffer: 16 * 1024 * 1024 },
);
const evidence = parseEvidence(JSON.parse(stdout));
assert.equal(evidence.subject.digest.sha256, sha256);
assert.deepEqual(
evidence.normalized_result.metadata_recovery,
metadata,
);
report.cli_metadata = evidence.normalized_result.metadata_recovery;
}
}
}
assert.equal(
hash(await readFile(target)),
sha256,
"Input executable changed",
assert.equal(failure.isError, true, JSON.stringify(failure));
const diagnostic = JSON.stringify(failure);
assert.match(
diagnostic,
mode === "unsupported"
? /Unsupported NativeAOT RTR format/
: mode === "malformed"
? /Reversed NativeAOT section/
: mode === "loader-failure"
? /ExceptionInInitializerError.*REA_INITIALIZER_FIXTURE/
: /Ambiguous NativeAOT directory candidates/,
);
if (opened) {
report = { mode, target, sha256, typed_failure: failure };
} else if (mode === "default-native") {
assert.notEqual(response.isError, true, JSON.stringify(response));
opened = true;
const image = nativeLoadImageSchema.parse(
await call("inspect_native_load_image"),
);
assert.equal(image.observations.metadata_recovery, undefined);
report = { mode, target, sha256, image };
} else {
assert.notEqual(response.isError, true, JSON.stringify(response));
opened = true;
const image = nativeLoadImageSchema.parse(
await call("inspect_native_load_image"),
);
const summary = image.observations.metadata_recovery?.[0];
assert.ok(
summary,
"Loaded-image inspection omitted inline runtime-format discovery",
);
assert.equal(summary.format, "dotnet-nativeaot");
assert.equal(
summary.analysis_artifact_sha256,
hash(await readFile(env.REA_GHIDRA_NATIVEAOT_JAR)),
);
if (mode === "ordinary") {
assert.equal(summary.status, "not_applicable");
assert.equal(summary.method_tables, 0);
assert.deepEqual(summary.types, []);
report = { mode, target, sha256, summary };
} else {
assert.ok(["complete", "partial"].includes(summary.status));
assert.equal(summary.format_major, 9);
assert.equal(summary.format_minor, 1);
assert.equal(summary.types.length, summary.method_tables);
assert.ok(summary.method_tables > 0);
assert.equal(
summary.discovery,
mode === "stripped" || oracle.runtime === "win-x64"
? "signature-heuristic"
: "symbol",
);
assert.equal(summary.derived_memory.file_offset, null);
assert.ok(summary.coverage.frozen_objects_annotated > 0);
// Independent compiler/linker symbols identify fixture classes. Recovered
// Class_address names are never treated as the original source identities.
const symbols = await readFile(join(fixtureRoot, "symbols.txt"), "utf8");
const bias =
oracle.runtime === "linux-x64"
? BigInt(image.observations.image_base)
: 0n;
const addressOf = (name) => {
const line = symbols
.split(/\r?\n/u)
.find((line) => line.split(/\s+/u).includes(name));
assert.ok(line, `Independent symbol missing: ${name}`);
const match =
oracle.runtime === "linux-x64"
? line.match(/^([a-f0-9]+)\s/iu)
: line.match(/\s([a-f0-9]{16})\s/iu);
assert.ok(match, `Unrecognized independent symbol line: ${line}`);
return `0x${(BigInt(`0x${match[1]}`) + bias).toString(16)}`;
};
const fixtureSymbols =
oracle.symbols ??
(oracle.runtime === "win-x64"
? {
base: "??_7NativeAotFixture_BaseProbe@@6B@",
derived: "??_7NativeAotFixture_DerivedProbe@@6B@",
interface: "??_7NativeAotFixture_IProbe@@6B@",
compute: "NativeAotFixture_DerivedProbe__Compute",
dispatch: "NativeAotFixture_Program__Dispatch",
}
: {
base: "_ZTV26NativeAotFixture_BaseProbe",
derived: "_ZTV29NativeAotFixture_DerivedProbe",
interface: "_ZTV23NativeAotFixture_IProbe",
compute: "NativeAotFixture_DerivedProbe__Compute",
dispatch: "NativeAotFixture_Program__Dispatch",
});
const lookup = (address) => {
const identity = summary.types.find((type) => type.address === address);
assert.ok(identity?.type, `Recovered identity missing at ${address}`);
return identity.type;
};
const derivedAddress = addressOf(fixtureSymbols.derived);
const baseAddress = addressOf(fixtureSymbols.base);
const interfaceAddress = addressOf(fixtureSymbols.interface);
const derived = nativeDataTypeSchema.parse(
await call("inspect_native_data_type", {
type: lookup(derivedAddress),
}),
);
const metadata = derived.metadata_recovery;
assert.ok(metadata);
assert.equal(metadata.original_name, null);
assert.equal(metadata.name_origin, "generated");
assert.equal(metadata.method_table_address, derivedAddress);
assert.equal(metadata.related_type.address, baseAddress);
assert.ok(
metadata.interfaces.some((type) => type.address === interfaceAddress),
);
assert.ok(
metadata.virtual_slots.some(
(slot) => slot.target_address === addressOf(fixtureSymbols.compute),
),
);
const compute = await call("analyze_function", {
procedure: addressOf(fixtureSymbols.compute),
detail: "complete",
});
assert.ok(compute.pseudocode.trim());
assert.doesNotMatch(
compute.pseudocode,
/\b__thiscall\b/u,
"Upstream x64 calling-convention rewrite leaked into recovered pseudocode",
);
assert.match(compute.pseudocode, /(?:0xb|\b11\b|<<)/u);
const dispatch = await call("analyze_function", {
procedure: addressOf(fixtureSymbols.dispatch),
detail: "complete",
});
assert.ok(dispatch.pseudocode.trim());
for (const literal of oracle.expected_strings)
assert.ok(
(await call("search_strings", { pattern: literal })).length > 0,
`Frozen string missing: ${literal}`,
);
const observed = await call("read_bytes", {
address: metadata.virtual_slots[0].slot_address,
length: 8,
});
assert.equal(observed.complete, true);
assert.equal(
`0x${Buffer.from(observed.bytes_hex, "hex").readBigUInt64LE().toString(16)}`,
metadata.virtual_slots[0].target_address,
);
report = { mode, target, sha256, summary, derived, compute, dispatch };
await call("close_binary");
opened = false;
// One representative CLI boundary, selected explicitly to avoid repeated
// full NativeAOT auto-analysis during each focused iteration.
if (process.env.REA_NATIVEAOT_PROOF_CLI === "1") {
const { stdout } = await promisify(execFile)(
process.execPath,
[
entrypoint,
"inspect-native-data-type",
target,
"--type",
lookup(derivedAddress),
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 360000, maxBuffer: 16 * 1024 * 1024 },
);
const evidence = parseEvidence(JSON.parse(stdout));
assert.equal(evidence.subject.digest.sha256, sha256);
assert.deepEqual(
evidence.normalized_result.metadata_recovery,
metadata,
);
report.cli_metadata = evidence.normalized_result.metadata_recovery;
}
}
} catch (error) {
console.error(stderr);
throw error;
}
}, [
async () => {
await closeVerifierResources([
async () => {
if (connected) await call("close_binary", {});
},
() => client.close(),
() => transport.close(),
]);
assert.deepEqual(
await readdir(runtime),
[],
"Owned runtime/project/extension files leaked after close",
);
verifierRun = await completeVerifierRun(run);
assert.equal(verifierRun.process_lineage.status, "verified");
assert.deepEqual(verifierRun.process_lineage.descendants, []);
await rm(workspace, { recursive: true, force: true });
},
]);
const capture = process.env.REA_NATIVEAOT_PROOF_CAPTURE_DIR;
if (capture !== undefined) {
assert.ok(isAbsolute(capture));
await mkdir(capture, { recursive: true });
await writeFile(
join(capture, `${oracle.runtime}-${mode}.json`),
JSON.stringify(report, null, 2) + "\n",
assert.equal(
hash(await readFile(target)),
sha256,
"Input executable changed",
);
if (opened) {
await call("close_binary");
opened = false;
}
await client.close();
assert.deepEqual(
await readdir(runtime),
[],
"Owned runtime/project/extension files leaked after close",
);
const capture = process.env.REA_NATIVEAOT_PROOF_CAPTURE_DIR;
if (capture !== undefined) {
assert.ok(isAbsolute(capture));
await mkdir(capture, { recursive: true });
await writeFile(
join(capture, `${oracle.runtime}-${mode}.json`),
JSON.stringify(report, null, 2) + "\n",
);
}
const outcome = report.typed_failure
? "MCP refusal"
: mode === "default-native"
? "native load image without metadata recovery"
: "MCP metadata";
console.log(
`PASS real Ghidra NativeAOT ${oracle.runtime}/${mode}: ${outcome}, source unchanged and runtime directory empty${report?.compute ? ", independent type/slot oracle and pseudocode" : ""}${report?.cli_metadata ? ", equivalent CLI" : ""}`,
);
} catch (error) {
console.error(stderr);
throw error;
} finally {
if (opened)
await client
.callTool({ name: "close_binary", arguments: {} })
.catch(() => {});
await client.close().catch(() => {});
await rm(workspace, { recursive: true, force: true });
}
const outcome = report.typed_failure
? "MCP refusal"
: mode === "default-native"
? "native load image without metadata recovery"
: "MCP metadata";
console.log(
`PASS real Ghidra NativeAOT ${oracle.runtime}/${mode}: ${outcome}, source unchanged and runtime directory empty${report?.compute ? ", independent type/slot oracle and pseudocode" : ""}${report?.cli_metadata ? ", equivalent CLI" : ""}`,
);
function hash(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
+1
View File
@@ -92,6 +92,7 @@ try {
const call = async (name, args = {}) => {
const reply = await client.callTool(
{ name, arguments: args },
undefined,
{ timeout: 180000 },
);
return requireMcpOperationResult(reply, name);
+454 -488
View File
@@ -29,10 +29,7 @@ import {
requireMcpOutput,
} from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
import {
withVerifierCleanup,
closeVerifierResources,
} from "./lib/verifier-cleanup.mjs";
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
if (process.argv.length !== 2)
throw new Error("Usage: node scripts/verify-real-ghidra-raw.mjs");
@@ -105,521 +102,490 @@ let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
let connected = false;
let verifierRun;
let report;
let searchDefinition;
await withVerifierCleanup(async () => {
try {
await client.connect(transport);
connected = true;
searchDefinition = (await client.listTools()).tools.find(
(tool) => tool.name === "search_bytes",
);
assert.ok(searchDefinition?.outputSchema);
const profiles = [];
const sessions = [];
for (const entryPoint of ["0x08000000", "0x8000000", "0x09000000"]) {
// Omit provider_id to exercise reuse of the current provider route.
const opened = await call("open_binary", {
path,
format: "raw-binary",
architecture: "arm",
entry_point: entryPoint,
});
const entry = `0x${BigInt(entryPoint).toString(16)}`;
assert.equal(opened.entry_point, entry);
const image = await call("inspect_native_load_image");
assert.equal(
image.normalized_result.status,
"verified",
JSON.stringify(image),
);
assert.equal(image.normalized_result.base_address, entry);
assert.equal(image.normalized_result.entry.address, entry);
assert.equal(
image.normalized_result.observations.language_id,
"ARM:LE:32:v7",
);
assert.ok(
image.normalized_result.observations.entry_points.includes(entry),
);
assert.equal(image.analysis_profile.parameters.entry_point, entry);
profiles.push(image.analysis_profile.digest);
const status = await call("binary_session");
assert.equal(status.entry_point, entry);
sessions.push(status.analysis_run.run_id);
const memory = await call("read_bytes", {
address: entry,
length: bytes.length,
});
assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex"));
const signature = await call("search_bytes", {
pattern: "1e ?? 2f e1",
limit: 1000,
});
assert.equal(signature.normalized_result.complete, true);
assert.equal(signature.normalized_result.next_cursor, null);
assert.equal(signature.normalized_result.searched.bytes, bytes.length);
assert.deepEqual(
signature.normalized_result.matches.map((match) => match.address),
[entry],
);
assert.equal(signature.normalized_result.matches[0].file_offset, 0);
assert.equal(
signature.normalized_result.matches[0].procedure.address,
entry,
);
const procedures = await call("list_procedures");
assert.ok(
procedures.normalized_result.some(
(procedure) => procedure.address === entry,
),
);
const instruction = await call("inspect_native_instruction", {
address: entry,
});
assert.equal(instruction.normalized_result.address, entry);
assert.ok(Array.isArray(instruction.normalized_result.decoder_context));
const tmodeContext = instruction.normalized_result.decoder_context.find(
(ctx) => ctx.register === "TMode",
);
assert.ok(tmodeContext !== undefined);
assert.equal(tmodeContext.source, "ghidra-program-context");
assert.ok([null, "0", "1"].includes(tmodeContext.value));
}
assert.equal(profiles[0], profiles[1]);
assert.notEqual(profiles[1], profiles[2]);
assert.equal(sessions[0], sessions[1]);
assert.notEqual(sessions[1], sessions[2]);
await call("annotate_native_function", {
procedure: "0x9000000",
name: "retained_after_failed_open",
comment: "Owned session annotation",
try {
await client.connect(transport);
searchDefinition = (await client.listTools()).tools.find(
(tool) => tool.name === "search_bytes",
);
assert.ok(searchDefinition?.outputSchema);
const profiles = [];
const sessions = [];
for (const entryPoint of ["0x08000000", "0x8000000", "0x09000000"]) {
// Omit provider_id to exercise reuse of the current provider route.
const opened = await call("open_binary", {
path,
format: "raw-binary",
architecture: "arm",
entry_point: entryPoint,
});
const rejected = await client.callTool({
name: "open_binary",
arguments: {
path,
format: "raw-binary",
architecture: "arm64",
entry_point: "0x8000001",
},
});
assert.equal(rejected.isError, true);
assert.match(JSON.stringify(rejected.content), /must be 4-byte aligned/);
const rejection = JSON.parse(rejected.content[0].text).error;
const entry = `0x${BigInt(entryPoint).toString(16)}`;
assert.equal(opened.entry_point, entry);
const image = await call("inspect_native_load_image");
assert.equal(
rejection.details.target_at_open_completion.entry_point,
"0x9000000",
image.normalized_result.status,
"verified",
JSON.stringify(image),
);
assert.equal(rejection.details.provider_id_at_open_completion, "ghidra");
const unchanged = await call("binary_session");
assert.equal(unchanged.analysis_run.run_id, sessions[2]);
assert.equal(unchanged.entry_point, "0x9000000");
const retained = await call("analyze_function", {
procedure: "0x9000000",
detail: "complete",
});
assert.equal(image.normalized_result.base_address, entry);
assert.equal(image.normalized_result.entry.address, entry);
assert.equal(
retained.normalized_result.procedure.name,
"retained_after_failed_open",
image.normalized_result.observations.language_id,
"ARM:LE:32:v7",
);
assert.ok(
retained.normalized_result.comments.some(
(comment) => comment.text === "Owned session annotation",
image.normalized_result.observations.entry_points.includes(entry),
);
assert.equal(image.analysis_profile.parameters.entry_point, entry);
profiles.push(image.analysis_profile.digest);
const status = await call("binary_session");
assert.equal(status.entry_point, entry);
sessions.push(status.analysis_run.run_id);
const memory = await call("read_bytes", {
address: entry,
length: bytes.length,
});
assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex"));
const signature = await call("search_bytes", {
pattern: "1e ?? 2f e1",
limit: 1000,
});
assert.equal(signature.normalized_result.complete, true);
assert.equal(signature.normalized_result.next_cursor, null);
assert.equal(signature.normalized_result.searched.bytes, bytes.length);
assert.deepEqual(
signature.normalized_result.matches.map((match) => match.address),
[entry],
);
assert.equal(signature.normalized_result.matches[0].file_offset, 0);
assert.equal(
signature.normalized_result.matches[0].procedure.address,
entry,
);
const procedures = await call("list_procedures");
assert.ok(
procedures.normalized_result.some(
(procedure) => procedure.address === entry,
),
);
await call("close_binary");
const { stdout } = await promisify(execFile)(
process.execPath,
[
entrypoint,
"inspect-native-load-image",
path,
"--target-format",
"raw-binary",
"--architecture",
"arm",
"--entry-point",
"0x08000000",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
const instruction = await call("inspect_native_instruction", {
address: entry,
});
assert.equal(instruction.normalized_result.address, entry);
assert.ok(Array.isArray(instruction.normalized_result.decoder_context));
const tmodeContext = instruction.normalized_result.decoder_context.find(
(ctx) => ctx.register === "TMode",
);
const cli = parseEvidence(JSON.parse(stdout));
assert.equal(cli.subject.digest.sha256, sha256);
assert.equal(cli.normalized_result.status, "verified");
assert.equal(cli.normalized_result.base_address, "0x8000000");
assert.equal(cli.analysis_profile.digest, profiles[0]);
assert.deepEqual(await readFile(path), bytes);
const x86Targets = [];
for (const architecture of ["x86", "x86_64"]) {
const x86Bytes = Buffer.from("b82a000000c3", "hex"); // mov eax, 42; ret.
const x86Sha256 = createHash("sha256").update(x86Bytes).digest("hex");
const x86Path = join(workspace, `${architecture}.bin`);
await writeFile(x86Path, x86Bytes);
await call("open_binary", {
path: x86Path,
format: "raw-binary",
architecture,
entry_point: "0x1000",
});
const image = await call("inspect_native_load_image", {}, x86Sha256);
assert.equal(
image.normalized_result.status,
"verified",
JSON.stringify(image),
);
assert.equal(image.normalized_result.base_address, "0x1000");
assert.equal(
image.normalized_result.observations.compiler_spec_id,
"gcc",
);
assert.equal(image.analysis_profile.parameters.compiler_spec_id, "gcc");
const memory = await call(
"read_bytes",
{ address: "0x1000", length: x86Bytes.length },
x86Sha256,
);
assert.equal(
memory.normalized_result.bytes_hex,
x86Bytes.toString("hex"),
);
const pseudo = await call(
"procedure_pseudo_code",
{ procedure: "0x1000" },
x86Sha256,
);
assert.match(pseudo.normalized_result, /\b(?:42|0x2a)\b/iu);
await call("close_binary");
assert.deepEqual(await readFile(x86Path), x86Bytes);
x86Targets.push({
architecture,
sha256: x86Sha256,
compiler_spec_id: "gcc",
return_42: true,
});
}
const repeatedPath = join(workspace, "signatures.bin");
const repeatedBytes = Buffer.from("b82a000000c3".repeat(1005), "hex");
const repeatedSha256 = createHash("sha256")
.update(repeatedBytes)
.digest("hex");
await writeFile(repeatedPath, repeatedBytes);
await call("open_binary", {
path: repeatedPath,
assert.ok(tmodeContext !== undefined);
assert.equal(tmodeContext.source, "ghidra-program-context");
assert.ok([null, "0", "1"].includes(tmodeContext.value));
}
assert.equal(profiles[0], profiles[1]);
assert.notEqual(profiles[1], profiles[2]);
assert.equal(sessions[0], sessions[1]);
assert.notEqual(sessions[1], sessions[2]);
await call("annotate_native_function", {
procedure: "0x9000000",
name: "retained_after_failed_open",
comment: "Owned session annotation",
});
const rejected = await client.callTool({
name: "open_binary",
arguments: {
path,
format: "raw-binary",
architecture: "x86_64",
architecture: "arm64",
entry_point: "0x8000001",
},
});
assert.equal(rejected.isError, true);
assert.match(JSON.stringify(rejected.content), /must be 4-byte aligned/);
const rejection = JSON.parse(rejected.content[0].text).error;
assert.equal(
rejection.details.target_at_open_completion.entry_point,
"0x9000000",
);
assert.equal(rejection.details.provider_id_at_open_completion, "ghidra");
const unchanged = await call("binary_session");
assert.equal(unchanged.analysis_run.run_id, sessions[2]);
assert.equal(unchanged.entry_point, "0x9000000");
const retained = await call("analyze_function", {
procedure: "0x9000000",
detail: "complete",
});
assert.equal(
retained.normalized_result.procedure.name,
"retained_after_failed_open",
);
assert.ok(
retained.normalized_result.comments.some(
(comment) => comment.text === "Owned session annotation",
),
);
await call("close_binary");
const { stdout } = await promisify(execFile)(
process.execPath,
[
entrypoint,
"inspect-native-load-image",
path,
"--target-format",
"raw-binary",
"--architecture",
"arm",
"--entry-point",
"0x08000000",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const cli = parseEvidence(JSON.parse(stdout));
assert.equal(cli.subject.digest.sha256, sha256);
assert.equal(cli.normalized_result.status, "verified");
assert.equal(cli.normalized_result.base_address, "0x8000000");
assert.equal(cli.analysis_profile.digest, profiles[0]);
assert.deepEqual(await readFile(path), bytes);
const x86Targets = [];
for (const architecture of ["x86", "x86_64"]) {
const x86Bytes = Buffer.from("b82a000000c3", "hex"); // mov eax, 42; ret.
const x86Sha256 = createHash("sha256").update(x86Bytes).digest("hex");
const x86Path = join(workspace, `${architecture}.bin`);
await writeFile(x86Path, x86Bytes);
await call("open_binary", {
path: x86Path,
format: "raw-binary",
architecture,
entry_point: "0x1000",
});
// Import/analysis is complete before timing the first uncached search.
await call("read_bytes", { address: "0x1000", length: 6 }, repeatedSha256);
const started = performance.now();
const page = await call(
"search_bytes",
{ pattern: "b8 ?? 00 00 00 c3", limit: 1000 },
repeatedSha256,
);
const searchElapsedMs = performance.now() - started;
assert.equal(page.normalized_result.matches.length, 1000);
assert.equal(page.normalized_result.complete, false);
const rest = await call(
"search_bytes",
{
pattern: "b8 ?? 00 00 00 c3",
limit: 1000,
cursor: page.normalized_result.next_cursor,
},
repeatedSha256,
);
assert.equal(rest.normalized_result.matches.length, 5);
const image = await call("inspect_native_load_image", {}, x86Sha256);
assert.equal(
rest.normalized_result.matches[0].address,
`0x${(0x1000 + 6000).toString(16)}`,
image.normalized_result.status,
"verified",
JSON.stringify(image),
);
assert.equal(rest.normalized_result.complete, true);
const masked = await call(
"search_bytes",
{ pattern: "bf", mask: "f0", start: "0x1000", end: "0x1005" },
repeatedSha256,
assert.equal(image.normalized_result.base_address, "0x1000");
assert.equal(image.normalized_result.observations.compiler_spec_id, "gcc");
assert.equal(image.analysis_profile.parameters.compiler_spec_id, "gcc");
const memory = await call(
"read_bytes",
{ address: "0x1000", length: x86Bytes.length },
x86Sha256,
);
assert.deepEqual(
masked.normalized_result.matches.map((match) => match.address),
["0x1000"],
assert.equal(memory.normalized_result.bytes_hex, x86Bytes.toString("hex"));
const pseudo = await call(
"procedure_pseudo_code",
{ procedure: "0x1000" },
x86Sha256,
);
const overlapping = await call(
"search_bytes",
{ pattern: "00 00", start: "0x1000", end: "0x1005" },
repeatedSha256,
);
assert.deepEqual(
overlapping.normalized_result.matches.map((match) => match.address),
["0x1002", "0x1003"],
);
const wrongCursor = await client.callTool({
name: "search_bytes",
arguments: { pattern: "c3", cursor: page.normalized_result.next_cursor },
});
assert.equal(wrongCursor.isError, true);
assert.match(pseudo.normalized_result, /\b(?:42|0x2a)\b/iu);
await call("close_binary");
assert.deepEqual(await readFile(repeatedPath), repeatedBytes);
assert.deepEqual(await readFile(x86Path), x86Bytes);
x86Targets.push({
architecture,
sha256: x86Sha256,
compiler_spec_id: "gcc",
return_42: true,
});
}
const repeatedPath = join(workspace, "signatures.bin");
const repeatedBytes = Buffer.from("b82a000000c3".repeat(1005), "hex");
const repeatedSha256 = createHash("sha256")
.update(repeatedBytes)
.digest("hex");
await writeFile(repeatedPath, repeatedBytes);
await call("open_binary", {
path: repeatedPath,
format: "raw-binary",
architecture: "x86_64",
entry_point: "0x1000",
});
// Import/analysis is complete before timing the first uncached search.
await call("read_bytes", { address: "0x1000", length: 6 }, repeatedSha256);
const started = performance.now();
const page = await call(
"search_bytes",
{ pattern: "b8 ?? 00 00 00 c3", limit: 1000 },
repeatedSha256,
);
const searchElapsedMs = performance.now() - started;
assert.equal(page.normalized_result.matches.length, 1000);
assert.equal(page.normalized_result.complete, false);
const rest = await call(
"search_bytes",
{
pattern: "b8 ?? 00 00 00 c3",
limit: 1000,
cursor: page.normalized_result.next_cursor,
},
repeatedSha256,
);
assert.equal(rest.normalized_result.matches.length, 5);
assert.equal(
rest.normalized_result.matches[0].address,
`0x${(0x1000 + 6000).toString(16)}`,
);
assert.equal(rest.normalized_result.complete, true);
const masked = await call(
"search_bytes",
{ pattern: "bf", mask: "f0", start: "0x1000", end: "0x1005" },
repeatedSha256,
);
assert.deepEqual(
masked.normalized_result.matches.map((match) => match.address),
["0x1000"],
);
const overlapping = await call(
"search_bytes",
{ pattern: "00 00", start: "0x1000", end: "0x1005" },
repeatedSha256,
);
assert.deepEqual(
overlapping.normalized_result.matches.map((match) => match.address),
["0x1002", "0x1003"],
);
const wrongCursor = await client.callTool({
name: "search_bytes",
arguments: { pattern: "c3", cursor: page.normalized_result.next_cursor },
});
assert.equal(wrongCursor.isError, true);
await call("close_binary");
assert.deepEqual(await readFile(repeatedPath), repeatedBytes);
// Two adjacent virtual regions backed by disjoint file ranges. The same
// signature crosses their boundary and overlaps at every possible start.
const splitPath = join(workspace, "split.elf");
const splitBytes = splitSearchElf(0);
const splitSha256 = createHash("sha256").update(splitBytes).digest("hex");
await writeFile(splitPath, splitBytes);
await call("open_binary", { path: splitPath, provider_id: "ghidra" });
let cursorReopened = false;
for (const pattern of ["aa aa aa", "aa ?? aa"]) {
let cursor;
const addresses = [];
const pages = [];
do {
const evidence = await call(
"search_bytes",
{
pattern,
start: "0x401000",
end: "0x401007",
limit: 2,
...(cursor === undefined ? {} : { cursor }),
},
splitSha256,
"elf",
);
const result = evidence.normalized_result;
addresses.push(...result.matches.map((match) => match.address));
pages.push({
matches: result.matches.map(({ address, segment, file_offset }) => ({
address,
segment,
file_offset,
})),
blocks: result.searched.blocks,
});
cursor = result.next_cursor ?? undefined;
if (cursor !== undefined && !cursorReopened) {
const snapshot = join(workspace, "split-snapshot.json");
await call("close_binary", { snapshot_path: snapshot });
await call("open_binary", {
path: splitPath,
provider_id: "ghidra",
snapshot_path: snapshot,
});
cursorReopened = true;
}
} while (cursor !== undefined);
assert.deepEqual(addresses, [
"0x401000",
"0x401001",
"0x401002",
"0x401003",
"0x401004",
"0x401005",
]);
assert.deepEqual(pages, [
{
matches: [
{ address: "0x401000", segment: "segment_0", file_offset: 0x1000 },
{ address: "0x401001", segment: "segment_0", file_offset: 0x1001 },
],
blocks: [
{
segment: "segment_0",
start: "0x401000",
end: "0x401003",
bytes: 4,
},
{
segment: "segment_1",
start: "0x401004",
end: "0x401004",
bytes: 1,
},
],
},
{
matches: [
{ address: "0x401002", segment: "segment_0", file_offset: 0x1002 },
{ address: "0x401003", segment: "segment_0", file_offset: 0x1003 },
],
blocks: [
{
segment: "segment_0",
start: "0x401002",
end: "0x401003",
bytes: 2,
},
{
segment: "segment_1",
start: "0x401004",
end: "0x401006",
bytes: 3,
},
],
},
{
matches: [
{ address: "0x401004", segment: "segment_1", file_offset: 0x3000 },
{ address: "0x401005", segment: "segment_1", file_offset: 0x3001 },
],
blocks: [
{
segment: "segment_1",
start: "0x401004",
end: "0x401007",
bytes: 4,
},
],
},
]);
const bounded = await call(
// Two adjacent virtual regions backed by disjoint file ranges. The same
// signature crosses their boundary and overlaps at every possible start.
const splitPath = join(workspace, "split.elf");
const splitBytes = splitSearchElf(0);
const splitSha256 = createHash("sha256").update(splitBytes).digest("hex");
await writeFile(splitPath, splitBytes);
await call("open_binary", { path: splitPath, provider_id: "ghidra" });
let cursorReopened = false;
for (const pattern of ["aa aa aa", "aa ?? aa"]) {
let cursor;
const addresses = [];
const pages = [];
do {
const evidence = await call(
"search_bytes",
{
pattern,
start: "0x401000",
end: "0x401005",
limit: 20,
end: "0x401007",
limit: 2,
...(cursor === undefined ? {} : { cursor }),
},
splitSha256,
"elf",
);
assert.deepEqual(
bounded.normalized_result.matches.map((match) => match.address),
addresses.slice(0, 4),
);
}
assert.equal(cursorReopened, true);
const fileStart = await call(
"address_to_file_offset",
{ address: "0x401000" },
splitSha256,
"elf",
);
const fileSecond = await call(
"address_to_file_offset",
{ address: "0x401004" },
splitSha256,
"elf",
);
assert.equal(fileStart.normalized_result.file_offset, 0x1000);
assert.equal(fileSecond.normalized_result.file_offset, 0x3000);
await call("close_binary");
assert.deepEqual(await readFile(splitPath), splitBytes);
await verifyCrossBlockProject(splitPath, splitSha256);
const gapPath = join(workspace, "gap.elf");
const gapBytes = splitSearchElf(1);
const gapSha256 = createHash("sha256").update(gapBytes).digest("hex");
await writeFile(gapPath, gapBytes);
await call("open_binary", { path: gapPath, provider_id: "ghidra" });
const gap = await call(
"search_bytes",
{
pattern: "aa aa aa aa ?? aa aa aa aa",
start: "0x401000",
end: "0x401008",
},
gapSha256,
"elf",
);
assert.deepEqual(gap.normalized_result.matches, []);
await call("close_binary");
assert.deepEqual(await readFile(gapPath), gapBytes);
const zeroFillPath = join(workspace, "initialized-zerofill.elf");
const zeroFillBytes = splitSearchElf(1, true);
const zeroFillSha256 = createHash("sha256")
.update(zeroFillBytes)
.digest("hex");
await writeFile(zeroFillPath, zeroFillBytes);
await call("open_binary", { path: zeroFillPath, provider_id: "ghidra" });
const zeroFill = await call(
"search_bytes",
{
pattern: "aa aa aa aa ?? aa aa aa aa",
start: "0x401000",
end: "0x401008",
},
zeroFillSha256,
"elf",
);
// This Ghidra loader initializes the ELF zero-fill byte in this fixture.
assert.deepEqual(
zeroFill.normalized_result.matches.map((match) => match.address),
["0x401000"],
);
assert.deepEqual(
zeroFill.normalized_result.searched.skipped_uninitialized,
[],
);
await call("close_binary");
assert.deepEqual(await readFile(zeroFillPath), zeroFillBytes);
report = {
ok: true,
mocked: false,
lane: "ghidra-raw",
host: process.platform,
provider: { id: "ghidra", version: installation.providerVersion },
target: {
format: "raw-binary",
architecture: "arm",
sha256,
bytes: bytes.length,
},
transport: { cli: true, stdio_mcp: true },
canonical_entry_reuses_session: true,
changed_entry_replaces_session: true,
lifecycle_entry_preserved: true,
invalid_entry_preserves_active_session: true,
load_images_verified: true,
original_unchanged: true,
x86_targets: x86Targets,
byte_search: {
cross_block_paging_and_gap: true,
matches: 1005,
page_size: 1000,
first_search_elapsed_ms: searchElapsedMs,
timing_scope: "MCP request after import; first uncached search",
token_usage: "unmeasured",
process_family_cpu_time: "unmeasured",
peak_process_family_memory: "unmeasured",
},
};
} catch (error) {
if (stderr.trim()) process.stderr.write(stderr);
throw error;
}
}, [
async () => {
await closeVerifierResources([
async () => {
if (connected) await call("close_binary", {});
},
() => client.close(),
() => transport.close(),
const result = evidence.normalized_result;
addresses.push(...result.matches.map((match) => match.address));
pages.push({
matches: result.matches.map(({ address, segment, file_offset }) => ({
address,
segment,
file_offset,
})),
blocks: result.searched.blocks,
});
cursor = result.next_cursor ?? undefined;
if (cursor !== undefined && !cursorReopened) {
const snapshot = join(workspace, "split-snapshot.json");
await call("close_binary", { snapshot_path: snapshot });
await call("open_binary", {
path: splitPath,
provider_id: "ghidra",
snapshot_path: snapshot,
});
cursorReopened = true;
}
} while (cursor !== undefined);
assert.deepEqual(addresses, [
"0x401000",
"0x401001",
"0x401002",
"0x401003",
"0x401004",
"0x401005",
]);
assert.deepEqual(pages, [
{
matches: [
{ address: "0x401000", segment: "segment_0", file_offset: 0x1000 },
{ address: "0x401001", segment: "segment_0", file_offset: 0x1001 },
],
blocks: [
{
segment: "segment_0",
start: "0x401000",
end: "0x401003",
bytes: 4,
},
{
segment: "segment_1",
start: "0x401004",
end: "0x401004",
bytes: 1,
},
],
},
{
matches: [
{ address: "0x401002", segment: "segment_0", file_offset: 0x1002 },
{ address: "0x401003", segment: "segment_0", file_offset: 0x1003 },
],
blocks: [
{
segment: "segment_0",
start: "0x401002",
end: "0x401003",
bytes: 2,
},
{
segment: "segment_1",
start: "0x401004",
end: "0x401006",
bytes: 3,
},
],
},
{
matches: [
{ address: "0x401004", segment: "segment_1", file_offset: 0x3000 },
{ address: "0x401005", segment: "segment_1", file_offset: 0x3001 },
],
blocks: [
{
segment: "segment_1",
start: "0x401004",
end: "0x401007",
bytes: 4,
},
],
},
]);
const bounded = await call(
"search_bytes",
{
pattern,
start: "0x401000",
end: "0x401005",
limit: 20,
},
splitSha256,
"elf",
);
assert.deepEqual(
(await readdir(runtime)).filter((name) => name.startsWith("rea-ghidra-")),
[],
"Owned Ghidra runtime survived close",
bounded.normalized_result.matches.map((match) => match.address),
addresses.slice(0, 4),
);
assert.equal(
fixtureCleaned,
true,
"Owned Java fixture cleanup remains pending",
);
verifierRun = await completeVerifierRun(run);
assert.equal(verifierRun.process_lineage.status, "verified");
assert.deepEqual(verifierRun.process_lineage.descendants, []);
await rm(workspace, { recursive: true, force: true });
},
]);
verifierRun = await completeVerifierRun(run);
}
assert.equal(cursorReopened, true);
const fileStart = await call(
"address_to_file_offset",
{ address: "0x401000" },
splitSha256,
"elf",
);
const fileSecond = await call(
"address_to_file_offset",
{ address: "0x401004" },
splitSha256,
"elf",
);
assert.equal(fileStart.normalized_result.file_offset, 0x1000);
assert.equal(fileSecond.normalized_result.file_offset, 0x3000);
await call("close_binary");
assert.deepEqual(await readFile(splitPath), splitBytes);
await verifyCrossBlockProject(splitPath, splitSha256);
const gapPath = join(workspace, "gap.elf");
const gapBytes = splitSearchElf(1);
const gapSha256 = createHash("sha256").update(gapBytes).digest("hex");
await writeFile(gapPath, gapBytes);
await call("open_binary", { path: gapPath, provider_id: "ghidra" });
const gap = await call(
"search_bytes",
{
pattern: "aa aa aa aa ?? aa aa aa aa",
start: "0x401000",
end: "0x401008",
},
gapSha256,
"elf",
);
assert.deepEqual(gap.normalized_result.matches, []);
await call("close_binary");
assert.deepEqual(await readFile(gapPath), gapBytes);
const zeroFillPath = join(workspace, "initialized-zerofill.elf");
const zeroFillBytes = splitSearchElf(1, true);
const zeroFillSha256 = createHash("sha256")
.update(zeroFillBytes)
.digest("hex");
await writeFile(zeroFillPath, zeroFillBytes);
await call("open_binary", { path: zeroFillPath, provider_id: "ghidra" });
const zeroFill = await call(
"search_bytes",
{
pattern: "aa aa aa aa ?? aa aa aa aa",
start: "0x401000",
end: "0x401008",
},
zeroFillSha256,
"elf",
);
// This Ghidra loader initializes the ELF zero-fill byte in this fixture.
assert.deepEqual(
zeroFill.normalized_result.matches.map((match) => match.address),
["0x401000"],
);
assert.deepEqual(
zeroFill.normalized_result.searched.skipped_uninitialized,
[],
);
await call("close_binary");
assert.deepEqual(await readFile(zeroFillPath), zeroFillBytes);
report = {
ok: true,
mocked: false,
lane: "ghidra-raw",
host: process.platform,
provider: { id: "ghidra", version: installation.providerVersion },
target: {
format: "raw-binary",
architecture: "arm",
sha256,
bytes: bytes.length,
},
transport: { cli: true, stdio_mcp: true },
canonical_entry_reuses_session: true,
changed_entry_replaces_session: true,
lifecycle_entry_preserved: true,
invalid_entry_preserves_active_session: true,
load_images_verified: true,
original_unchanged: true,
x86_targets: x86Targets,
byte_search: {
cross_block_paging_and_gap: true,
matches: 1005,
page_size: 1000,
first_search_elapsed_ms: searchElapsedMs,
timing_scope: "MCP request after import; first uncached search",
token_usage: "unmeasured",
process_family_cpu_time: "unmeasured",
peak_process_family_memory: "unmeasured",
},
};
} catch (error) {
if (stderr.trim()) process.stderr.write(stderr);
throw error;
} finally {
await client.close();
await transport.close();
if (fixtureCleaned) await rm(workspace, { recursive: true, force: true });
}
const verifierRun = await completeVerifierRun(run);
assert.equal(verifierRun.process_lineage.status, "verified");
assert.deepEqual(verifierRun.process_lineage.descendants, []);
process.stdout.write(
+15 -29
View File
@@ -1,8 +1,4 @@
#!/usr/bin/env node
import {
closeVerifierResources,
withVerifierCleanup,
} from "./lib/verifier-cleanup.mjs";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import {
@@ -75,11 +71,9 @@ const literalObjects = ["decoder-safeguards.S", "unresolved-string-jump.s"].map(
}),
);
const reports = [];
let connected = false;
let verifierRun;
let literalReport;
let decoderCleaned = true;
await withVerifierCleanup(async () => {
try {
await execute("swiftc", ["-O", "-g", source, "-o", binary], {
timeout: 60_000,
});
@@ -116,13 +110,12 @@ await withVerifierCleanup(async () => {
);
await verifyDecoderSafeguards();
await client.connect(transport);
connected = true;
for (const [variant, path] of [
["debug", binary],
["without-dsym", copied],
]) {
await call("open_binary", { path, provider_id: "ghidra" });
await withVerifierCleanup(async () => {
try {
const procedures = await call("list_procedures", {});
const inventory = await call("analyze_swift_types", {});
assert(
@@ -190,25 +183,16 @@ await withVerifierCleanup(async () => {
metadata_accessors: metadata.total,
unclassified: inventory.unclassified.length,
});
}, [() => call("close_binary", {})]);
} finally {
await call("close_binary", {});
}
}
literalReport = await verifyStringLiterals();
}, [
async () => {
await closeVerifierResources([
async () => {
if (connected) await call("close_binary", {});
},
() => client.close(),
() => transport.close(),
]);
assert.equal(decoderCleaned, true, "Owned decoder cleanup remains pending");
verifierRun = await completeVerifierRun(run);
assert.equal(verifierRun.process_lineage.status, "verified");
assert.deepEqual(verifierRun.process_lineage.descendants, []);
await rm(root, { recursive: true, force: true });
},
]);
} finally {
await client.close();
await transport.close();
if (decoderCleaned) await rm(root, { recursive: true, force: true });
}
console.log(
JSON.stringify(
{
@@ -216,7 +200,7 @@ console.log(
provider: "ghidra",
fixtures: reports,
swift_string_literals: literalReport,
verifier_run: verifierRun,
verifier_run: await completeVerifierRun(run),
},
null,
2,
@@ -236,7 +220,7 @@ async function call(name, arguments_) {
// Literal text lives only in register constants, so it must come from the decoded pairs.
async function verifyStringLiterals() {
await call("open_binary", { path: literalBinary, provider_id: "ghidra" });
return withVerifierCleanup(async () => {
try {
const procedures = await call("list_procedures", {});
const expected = {
smallLiteral: [["SleepDisabled", "small"]],
@@ -297,7 +281,9 @@ async function verifyStringLiterals() {
decoded.unresolvedStringJump =
unresolvedDossier.swift_string_literal_omissions.length;
return decoded;
}, [() => call("close_binary", {})]);
} finally {
await call("close_binary", {});
}
}
// Exercise the Java producer inside an ephemeral Ghidra database. The explicit
+17 -16
View File
@@ -1,5 +1,4 @@
#!/usr/bin/env node
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
import assert from "node:assert/strict";
import {
requireMcpOperationResult,
@@ -96,7 +95,7 @@ await writeFile(source, buildSwitchSource());
const flags = ["-O2", "-g", "-fno-pie", "-no-pie"];
const results = [];
let productionControls = null;
await withVerifierCleanup(async () => {
try {
for (const compiler of ["gcc", "clang"]) {
const original = join(workspace, `${compiler}-optimized`);
await exec(commands[compiler], [...flags, source, "-o", original]);
@@ -170,19 +169,17 @@ await withVerifierCleanup(async () => {
});
}
}
}, [
async () => {
} finally {
try {
assert.deepEqual(
(await readdir(runtime)).filter((name) => name.startsWith("rea-ghidra-")),
[],
"Owned Ghidra runtime survived close",
);
const cleanup = await completeVerifierRun(run);
assert.equal(cleanup.process_lineage.status, "verified");
assert.deepEqual(cleanup.process_lineage.descendants, []);
} finally {
await rm(workspace, { recursive: true, force: true });
},
]);
}
}
const lifecycle = await completeVerifierRun(run);
if (lifecycle.process_lineage.status === "verified")
assert.deepEqual(lifecycle.process_lineage.descendants, []);
@@ -215,7 +212,7 @@ async function verifyTarget(path, sha256, fixtures, variant) {
let opened = false;
let stderr = "";
const observations = [];
return withVerifierCleanup(async () => {
try {
await client.connect(transport);
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
@@ -290,13 +287,17 @@ async function verifyTarget(path, sha256, fixtures, variant) {
});
}
return { observations, stderr };
}, [
async () => {
} finally {
try {
if (opened) await call(client, "close_binary");
},
() => client.close(),
() => transport.close(),
]);
} finally {
try {
await client.close();
} finally {
await transport.close();
}
}
}
}
function assertWorkflowIdentity(evidence, sha256) {
assert.equal(evidence.subject.digest.sha256, sha256);
+14 -15
View File
@@ -196,6 +196,17 @@ try {
assert.equal(value.threads.length, 1);
cases++;
}
for (const [name, kind, label] of [
["aarch64-archext", 0x70000000, "PT_AARCH64_ARCHEXT"],
["aarch64-unwind", 0x70000001, "PT_AARCH64_UNWIND"],
]) {
const value = await basic.inspect(mode, fixtures[name]);
assert.equal(value.architecture, "aarch64-little-endian");
assert.equal(value.segments[0]?.type_id, kind);
assert.equal(value.segments[0]?.type, label);
assert.equal(value.threads.length, 0);
cases++;
}
const noSignals = await basic.inspect(mode, fixtures["unknown-signals"]);
assert.equal(noSignals.signals.length, 0);
assert.equal(noSignals.threads.length, 2);
@@ -205,19 +216,9 @@ try {
assert.equal(noNotes.threads.length, 0);
assert.equal(noNotes.note_interpretation_completeness, "unknown");
cases++;
for (const [name, architecture, type, typeId] of [
[
"aarch64-archext",
"aarch64-little-endian",
"PT_AARCH64_ARCHEXT",
0x70000000,
],
[
"aarch64-unwind",
"aarch64-little-endian",
"PT_AARCH64_UNWIND",
0x70000001,
],
for (const [name, architecture, type] of [
["aarch64-archext", "aarch64-little-endian", "PT_AARCH64_ARCHEXT"],
["aarch64-unwind", "aarch64-little-endian", "PT_AARCH64_UNWIND"],
["aarch64-mte-opaque", "aarch64-little-endian", 0x70000002],
["amd64-processor-opaque", "x86_64-little-endian", 0x70000001],
]) {
@@ -225,8 +226,6 @@ try {
assert.equal(auxiliary.architecture, architecture);
assert.equal(auxiliary.segments.length, 1);
assert.equal(auxiliary.segments[0].type, type);
if (typeId !== undefined)
assert.equal(auxiliary.segments[0].type_id, typeId);
assert.equal(auxiliary.notes.length, 0);
assert.equal(auxiliary.threads.length, 0);
assert.equal(auxiliary.note_interpretation_completeness, "unknown");
+10 -15
View File
@@ -1,5 +1,3 @@
import { createPackageVerifierCommands } from "./lib/verify-package-commands.mjs";
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
@@ -28,25 +26,22 @@ const environment = {
npm_config_cache: join(workspace, "npm-cache"),
};
delete environment.HOPPER_TARGET_PATH;
const commands = await createPackageVerifierCommands({
workspace: {
path: workspace,
close: () => rm(workspace, { recursive: true, force: true }),
},
});
let result;
await withVerifierCleanup(async () => {
try {
const packageRoot = await installCanaryPackage(
source,
workspace,
environment,
commands,
);
result = await verifyInstalledCanaryPackage(packageRoot, version, workspace, {
environment: environment,
commands,
});
}, [() => commands.close()]);
result = await verifyInstalledCanaryPackage(
packageRoot,
version,
workspace,
environment,
);
} finally {
await rm(workspace, { recursive: true, force: true });
}
process.stdout.write(
`${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), package: "rea-agents", version, source, ...result })}\n`,
);
File diff suppressed because it is too large. Load diff
@@ -66,18 +66,7 @@ try {
} else {
// Overview controls verify identity, ABI and snapshot section identities;
// they deliberately do not require any printable string values.
const section = result.libapp.sections.find(
({ name }) => name === oracle.section.name,
);
assert.ok(
section,
"Overview omitted the independently pinned snapshot section",
);
assert.deepEqual(
{ name: section.name, offset: section.offset, size: section.size },
oracle.section,
"Overview snapshot section identity differs from the independent ELF oracle",
);
assert.ok(result.libapp.sections.length > 0);
}
const tokenEstimate = calls.reduce(
(n, call) => n + call.request_token_estimate + call.reply_token_estimate,
@@ -13,28 +13,14 @@ def main():
parser.add_argument('inputs', type=Path)
parser.add_argument('output', type=Path)
options = parser.parse_args()
expected = {f'r{trial}-{task}-{budget}' for trial in [1, 2, 3]
for task in ['theme', 'ms'] for budget in ['default', 'double']}
paths = sorted(options.inputs.glob('*.metrics.json'))
observed = {path.name.removesuffix('.metrics.json') for path in paths}
if observed != expected:
raise ValueError(f'Expected the complete twelve-trial receipt set; missing={sorted(expected - observed)}, unexpected={sorted(observed - expected)}')
encoder = tiktoken.get_encoding('cl100k_base')
def compact(value):
return json.dumps(value, ensure_ascii=False, separators=(',', ':'))
rows = []
for path in paths:
for path in sorted(options.inputs.glob('*.metrics.json')):
metrics = json.loads(path.read_text())
if not (metrics['exit_code'] == 0 and metrics['stop_reason'] is None
and metrics['observer_error'] is None and metrics['cleanup'] == 'group_empty'
and metrics['remaining_group_pids'] == [] and metrics['signal_errors'] == []):
raise ValueError(f'Unsuccessful or unconfirmed observer receipt: {path.name}')
record = json.loads(path.with_name(path.name.replace('.metrics.json', '.replies.json')).read_text())
summary = json.loads(path.with_suffix('.stdout').read_text())
name = path.name.removesuffix('.metrics.json')
if (record['exit_code'] != 0 or
name != f"{name.split('-')[0]}-{summary['case']}-{summary['budget']}"):
raise ValueError(f'CLI receipt identity or completion disagrees: {path.name}')
tokens = {
'arguments': len(encoder.encode(compact(record['argv']))),
'reply': len(encoder.encode(compact(json.loads(record['stdout'])))),
+8 -15
View File
@@ -1,9 +1,9 @@
import assert from "node:assert/strict";
import { createPackageVerifierCommands } from "../../lib/verify-package-commands.mjs";
import { withVerifierCleanup } from "../../lib/verifier-cleanup.mjs";
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import {
artifactCliEvidence,
@@ -19,6 +19,8 @@ import { verifyDylibResolution } from "./macos-dylib-e2e.mjs";
import { verifyMachOOccurrences } from "./macho-occurrences.mjs";
import { verifyNativeProvenance } from "./native-provenance.mjs";
const exec = promisify(execFile);
await preflightMacosBundleFixture();
const root = await mkdtemp(join(tmpdir(), "rea-macos-bundle-"));
@@ -65,19 +67,8 @@ const EXPECTED_BUNDLES = [
["Contents/XPCServices/Svc.xpc", "xpc-service", "macos-deep", "."],
];
const commands = await createPackageVerifierCommands({
workspace: {
path: root,
close: () => rm(root, { recursive: true, force: true }),
},
});
const exec = (command, args) =>
commands.exec(command, args, {
timeout: 120_000,
maxBuffer: 16 * 1024 * 1024,
});
let report;
await withVerifierCleanup(async () => {
try {
const { app, executable } = await buildMacosBundleFixture(root);
const nativeProvenance = await verifyNativeProvenance(app, executable);
const machoOccurrences = await verifyMachOOccurrences(app, executable);
@@ -182,5 +173,7 @@ await withVerifierCleanup(async () => {
dylib_resolution: dylibs,
dyld_environment: dyldEnvironment,
};
}, [() => commands.close()]);
} finally {
await rm(root, { recursive: true, force: true });
}
process.stdout.write(`${JSON.stringify(report)}\n`);
+8 -15
View File
@@ -1,10 +1,10 @@
import assert from "node:assert/strict";
import { createPackageVerifierCommands } from "../../lib/verify-package-commands.mjs";
import { withVerifierCleanup } from "../../lib/verifier-cleanup.mjs";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { toolContract } from "../../../dist/contracts/toolContracts.js";
import {
@@ -14,6 +14,8 @@ import {
withBundleResourcesMcp,
} from "../../lib/artifact-e2e.mjs";
const exec = promisify(execFile);
// dyld_info abbreviates a terminal .dylib suffix; padding is part of the name.
const INSTALL_NAME_CASES = [
{ installName: "leading.dylib", importSource: "leading" },
@@ -180,18 +182,7 @@ export async function verifyNativeProvenance(app, executable) {
async function verifyInstallNames() {
const root = await mkdtemp(join(tmpdir(), "rea-macho-install-names-"));
const observations = [];
const commands = await createPackageVerifierCommands({
workspace: {
path: root,
close: () => rm(root, { recursive: true, force: true }),
},
});
const exec = (command, args) =>
commands.exec(command, args, {
timeout: 120_000,
maxBuffer: 16 * 1024 * 1024,
});
return withVerifierCleanup(async () => {
try {
const librarySource = join(root, "library.c");
const consumerSource = join(root, "consumer.c");
await writeFile(librarySource, "int exported_value(void) { return 42; }\n");
@@ -288,5 +279,7 @@ async function verifyInstallNames() {
});
}
return observations;
}, [() => commands.close()]);
} finally {
await rm(root, { recursive: true, force: true });
}
}
+206 -245
View File
@@ -1,7 +1,3 @@
import {
closeVerifierResources,
withVerifierCleanup,
} from "../../lib/verifier-cleanup.mjs";
import { requireMcpToolError } from "../../lib/mcp-verifier-results.mjs";
import assert from "node:assert/strict";
import { execFile, spawn } from "node:child_process";
@@ -96,221 +92,207 @@ bridge.exposeInMainWorld("fixture", {
});
let application;
let stderr = "";
await withVerifierCleanup(async () => {
try {
await readProcessRunId(process.pid);
application = spawn(
executable,
[
"--remote-debugging-address=127.0.0.1",
"--remote-debugging-port=0",
`--user-data-dir=${join(root, "profile")}`,
main,
],
{
cwd: root,
env: { ...process.env, REA_PROCESS_RUN_ID: runId },
detached: true,
stdio: ["ignore", "ignore", "pipe"],
},
);
application.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
const endpoint = await waitForEndpoint(application, () => stderr);
await client.connect(transport);
const ajv = new Ajv2020({ strict: false, validateFormats: false });
const validators = new Map(
(await client.listTools()).tools.map((tool) => {
ajv.compile(tool.inputSchema);
return [
tool.name,
tool.outputSchema
? (tool.outputSchema.$id &&
ajv.getSchema(tool.outputSchema.$id)) ||
ajv.compile(tool.outputSchema)
: undefined,
];
}),
);
const call = async (name, arguments_) => {
console.log(`PROBE ${name}`);
const result = await client.callTool(
{ name, arguments: arguments_ },
{ timeout: 150_000 },
);
assert.notEqual(result.isError, true, JSON.stringify(result));
const validate = validators.get(name);
assert.ok(
validate?.(result.structuredContent),
JSON.stringify(validate?.errors),
);
return result.structuredContent;
};
let targets;
for (let attempt = 0; attempt < 30; attempt++) {
targets = await call("list_electron_targets", {
cdp_endpoint: endpoint,
});
if (targets.normalized_result.targets.length > 0) break;
await pause(100);
}
const target = targets.normalized_result.targets.find(
(item) => item.file_path === join(root, "renderer.html"),
);
assert.ok(target, JSON.stringify(targets));
const page = await call("inspect_electron_page", {
cdp_endpoint: endpoint,
target_id: target.target_id,
include_script_sources: true,
observation_ms: 50,
});
assert.ok(
page.normalized_result.scripts.items.some(
(script) =>
script.file_path === join(root, "renderer.js") &&
script.source.included,
),
);
const staticAnalysis = await call("analyze_javascript_application", {
input_path: root,
});
assert.equal(staticAnalysis.normalized_result.summary.browser_windows, 1);
assert.equal(
staticAnalysis.normalized_result.summary.preload_entrypoints,
1,
);
assert.equal(
staticAnalysis.normalized_result.summary.context_bridge_apis,
1,
);
assert.equal(
staticAnalysis.normalized_result.summary.ipc.main_handlers,
1,
);
assert.equal(
staticAnalysis.normalized_result.summary.ipc.renderer_transmissions,
1,
);
assert.equal(
staticAnalysis.normalized_result.summary.ipc
.paired_renderer_transmissions,
1,
);
const reconciled = await call("reconcile_javascript_runtime", {
static_layers: [
{
analysis: staticAnalysis,
runtime_mappings: [{ kind: "file-root", root }],
},
],
runtime_observations: [page],
});
assert.ok(reconciled.normalized_result.runtime_captures.length > 0);
const cli = await execute(
process.execPath,
[
entrypoint,
"inspect-electron-page",
endpoint,
target.target_id,
"--include-script-sources",
"--json",
],
{
env: { ...process.env, REA_LOG_LEVEL: "silent" },
timeout: 60_000,
maxBuffer: 32 * 1024 * 1024,
},
);
assert.equal(
JSON.parse(cli.stdout).normalized_result.target.file_path,
target.file_path,
);
const input = {
executable_path: executable,
application_path: main,
application_root: root,
actions: [{ step_id: "ipc", kind: "click", selector: "#run" }],
};
const active = await client.callTool(
{ name: "capture_electron_scenario", arguments: input },
try {
await readProcessRunId(process.pid);
application = spawn(
executable,
[
"--remote-debugging-address=127.0.0.1",
"--remote-debugging-port=0",
`--user-data-dir=${join(root, "profile")}`,
main,
],
{
cwd: root,
env: { ...process.env, REA_PROCESS_RUN_ID: runId },
detached: true,
stdio: ["ignore", "ignore", "pipe"],
},
);
application.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
const endpoint = await waitForEndpoint(application, () => stderr);
await client.connect(transport);
const ajv = new Ajv2020({ strict: false, validateFormats: false });
const validators = new Map(
(await client.listTools()).tools.map((tool) => {
ajv.compile(tool.inputSchema);
return [
tool.name,
tool.outputSchema
? (tool.outputSchema.$id && ajv.getSchema(tool.outputSchema.$id)) ||
ajv.compile(tool.outputSchema)
: undefined,
];
}),
);
const call = async (name, arguments_) => {
console.log(`PROBE ${name}`);
const result = await client.callTool(
{ name, arguments: arguments_ },
{ timeout: 150_000 },
);
const activeError = active.isError
? requireMcpToolError(active)
: undefined;
if (activeError !== undefined)
assert.equal(
activeError.code,
"cleanup_incomplete",
JSON.stringify(activeError),
);
const capture =
activeError === undefined
? active.structuredContent.normalized_result
: activeError.details?.partial_observation?.capture;
assert.ok(capture, JSON.stringify(activeError ?? active));
if (activeError !== undefined) {
assert.equal(capture.application.cleanup, "unverified");
} else {
assert.ok(
validators.get("capture_electron_scenario")(active.structuredContent),
);
assert.equal(capture.application.cleanup, "terminated-owned-process");
}
assert.equal(capture.actions[0].status, "completed");
assert.notEqual(result.isError, true, JSON.stringify(result));
const validate = validators.get(name);
assert.ok(
capture.ipc.events.some(
({ kind, channel }) =>
kind === "main-handler-invocation" && channel === "fixture:echo",
),
JSON.stringify(capture.ipc),
);
assert.equal(
capture.coverage.status,
"partial_attach",
JSON.stringify(capture),
);
assert.ok(
capture.timeline.events.some(
(event) =>
event.kind === "window-lifecycle" && event.event === "created",
),
);
await client.ping();
console.log(
`PASS live Electron CLI/MCP discovery, inspection, reconciliation, and active observations; active cleanup=${capture.application.cleanup}`,
);
if (process.platform !== "win32")
await verifyCancellation({ input, ready, entrypoint, root });
} catch (cause) {
throw new Error(
`Electron public-tool verification failed: ${cause.message}\nMCP stderr:\n${serverStderr}`,
{ cause },
validate?.(result.structuredContent),
JSON.stringify(validate?.errors),
);
return result.structuredContent;
};
let targets;
for (let attempt = 0; attempt < 30; attempt++) {
targets = await call("list_electron_targets", { cdp_endpoint: endpoint });
if (targets.normalized_result.targets.length > 0) break;
await pause(100);
}
}, [
async () => {
await closeVerifierResources([
() => client.close(),
() => transport.close(),
async () => {
if (application?.pid) {
const cleanup = await cleanupOwnedProcessGroup({
runId,
leaderPid: application.pid,
processGroupId: application.pid,
expectedParentPid: process.pid,
expectedCommand: executable,
});
assert.equal(cleanup.cleaned, true, JSON.stringify(cleanup));
}
const target = targets.normalized_result.targets.find(
(item) => item.file_path === join(root, "renderer.html"),
);
assert.ok(target, JSON.stringify(targets));
const page = await call("inspect_electron_page", {
cdp_endpoint: endpoint,
target_id: target.target_id,
include_script_sources: true,
observation_ms: 50,
});
assert.ok(
page.normalized_result.scripts.items.some(
(script) =>
script.file_path === join(root, "renderer.js") &&
script.source.included,
),
);
const staticAnalysis = await call("analyze_javascript_application", {
input_path: root,
});
assert.equal(staticAnalysis.normalized_result.summary.browser_windows, 1);
assert.equal(
staticAnalysis.normalized_result.summary.preload_entrypoints,
1,
);
assert.equal(
staticAnalysis.normalized_result.summary.context_bridge_apis,
1,
);
assert.equal(staticAnalysis.normalized_result.summary.ipc.main_handlers, 1);
assert.equal(
staticAnalysis.normalized_result.summary.ipc.renderer_transmissions,
1,
);
assert.equal(
staticAnalysis.normalized_result.summary.ipc
.paired_renderer_transmissions,
1,
);
const reconciled = await call("reconcile_javascript_runtime", {
static_layers: [
{
analysis: staticAnalysis,
runtime_mappings: [{ kind: "file-root", root }],
},
]);
await rm(root, { recursive: true, force: true });
},
]);
],
runtime_observations: [page],
});
assert.ok(reconciled.normalized_result.runtime_captures.length > 0);
const cli = await execute(
process.execPath,
[
entrypoint,
"inspect-electron-page",
endpoint,
target.target_id,
"--include-script-sources",
"--json",
],
{
env: { ...process.env, REA_LOG_LEVEL: "silent" },
timeout: 60_000,
maxBuffer: 32 * 1024 * 1024,
},
);
assert.equal(
JSON.parse(cli.stdout).normalized_result.target.file_path,
target.file_path,
);
const input = {
executable_path: executable,
application_path: main,
application_root: root,
actions: [{ step_id: "ipc", kind: "click", selector: "#run" }],
};
const active = await client.callTool(
{ name: "capture_electron_scenario", arguments: input },
{ timeout: 150_000 },
);
const activeError = active.isError
? requireMcpToolError(active)
: undefined;
if (activeError !== undefined)
assert.equal(
activeError.code,
"cleanup_incomplete",
JSON.stringify(activeError),
);
const capture =
activeError === undefined
? active.structuredContent.normalized_result
: activeError.details?.partial_observation?.capture;
assert.ok(capture, JSON.stringify(activeError ?? active));
if (activeError !== undefined) {
assert.equal(capture.application.cleanup, "unverified");
} else {
assert.ok(
validators.get("capture_electron_scenario")(active.structuredContent),
);
assert.equal(capture.application.cleanup, "terminated-owned-process");
}
assert.equal(capture.actions[0].status, "completed");
assert.ok(
capture.ipc.events.some(
({ kind, channel }) =>
kind === "main-handler-invocation" && channel === "fixture:echo",
),
JSON.stringify(capture.ipc),
);
assert.equal(
capture.coverage.status,
"partial_attach",
JSON.stringify(capture),
);
assert.ok(
capture.timeline.events.some(
(event) =>
event.kind === "window-lifecycle" && event.event === "created",
),
);
await client.ping();
console.log(
`PASS live Electron CLI/MCP discovery, inspection, reconciliation, and active observations; active cleanup=${capture.application.cleanup}`,
);
if (process.platform !== "win32")
await verifyCancellation({ input, ready, entrypoint, root });
} catch (cause) {
throw new Error(
`Electron public-tool verification failed: ${cause.message}\nMCP stderr:\n${serverStderr}`,
{ cause },
);
} finally {
await client.close();
await transport.close();
if (application?.pid) {
const cleanup = await cleanupOwnedProcessGroup({
runId,
leaderPid: application.pid,
processGroupId: application.pid,
expectedParentPid: process.pid,
expectedCommand: executable,
});
assert.equal(cleanup.cleaned, true, JSON.stringify(cleanup));
}
await rm(root, { recursive: true, force: true });
}
}
const pause = (milliseconds) =>
@@ -363,7 +345,7 @@ async function verifyCancellation({ input, ready, entrypoint, root }) {
child.once("exit", (code, signal) => resolve({ code, signal }));
});
let ownership;
await withVerifierCleanup(async () => {
try {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const table = (
@@ -437,35 +419,14 @@ async function verifyCancellation({ input, ready, entrypoint, root }) {
console.log(
`PASS real Electron CLI SIGTERM, retained cancelled actions, and owned group termination; reported code=${error.code}`,
);
}, [
async () => {
for (const signal of ["SIGTERM", "SIGKILL"]) {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill(signal);
let timer;
try {
if (
await Promise.race([
exited.then(() => true),
new Promise((resolve) => {
timer = setTimeout(() => resolve(false), 2000);
}),
])
)
return;
} finally {
clearTimeout(timer);
}
}
throw new Error(
"Electron verifier CLI did not exit after SIGKILL; application cleanup remains independently owned",
);
},
async () => {
if (ownership) {
const cleanup = await cleanupOwnedProcessGroup(ownership);
assert.equal(cleanup.cleaned, true, JSON.stringify(cleanup));
}
},
]);
} finally {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGTERM");
await exited;
}
if (ownership) {
const cleanup = await cleanupOwnedProcessGroup(ownership);
assert.equal(cleanup.cleaned, true, JSON.stringify(cleanup));
}
}
}
+18 -27
View File
@@ -1,29 +1,17 @@
import assert from "node:assert/strict";
import { createPackageVerifierCommands } from "../../lib/verify-package-commands.mjs";
import { withVerifierCleanup } from "../../lib/verifier-cleanup.mjs";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Optional verification tool only; the runtime inspector never starts dtc.
const dtc = process.env.REA_DTC_PATH ?? "dtc";
assert.match(
execFileSync(dtc, ["--version"], { encoding: "utf8" }),
/DTC 1\.8\.1\b/,
);
const root = mkdtempSync(join(tmpdir(), "rea-dtb-verification-"));
const commands = await createPackageVerifierCommands({
workspace: {
path: root,
close: async () => rmSync(root, { recursive: true, force: true }),
},
});
const exec = async (command, args) =>
(
await commands.exec(command, args, {
timeout: 120_000,
maxBuffer: 16 * 1024 * 1024,
})
).stdout;
await withVerifierCleanup(async () => {
assert.match(await exec(dtc, ["--version"]), /DTC 1\.8\.1\b/);
try {
const source = join(root, "board.dts"),
blob = join(root, "board.dtb");
writeFileSync(
@@ -42,15 +30,16 @@ await withVerifierCleanup(async () => {
consumer { remote-endpoint = <&serial>; };
};\n`,
);
await exec(dtc, ["-I", "dts", "-O", "dtb", "-o", blob, source]);
const decompiled = await exec(dtc, ["-I", "dtb", "-O", "dts", blob]);
execFileSync(dtc, ["-I", "dts", "-O", "dtb", "-o", blob, source]);
const decompiled = execFileSync(dtc, ["-I", "dtb", "-O", "dts", blob], {
encoding: "utf8",
});
const evidence = JSON.parse(
await exec(process.execPath, [
"scripts/rea.mjs",
"inspect-devicetree",
blob,
"--json",
]),
execFileSync(
process.execPath,
["scripts/rea.mjs", "inspect-devicetree", blob, "--json"],
{ encoding: "utf8" },
),
);
const report = evidence.normalized_result;
// This controlled fixture uses one opening/closing brace per node line.
@@ -94,4 +83,6 @@ await withVerifierCleanup(async () => {
2,
),
);
}, [() => commands.close()]);
} finally {
rmSync(root, { recursive: true, force: true });
}
@@ -1,7 +1,3 @@
import {
closeVerifierResources,
withVerifierCleanup,
} from "../../lib/verifier-cleanup.mjs";
import assert from "node:assert/strict";
import { execFile, spawn } from "node:child_process";
import { once } from "node:events";
@@ -42,7 +38,7 @@ const names = [
...(process.platform === "win32" ? [] : ['with"quote.cjs']),
];
await withVerifierCleanup(async () => {
try {
await client.connect(transport);
for (const name of names) {
const targetPath =
@@ -58,20 +54,19 @@ await withVerifierCleanup(async () => {
stdio: ["ignore", "ignore", "pipe"],
},
);
await withVerifierCleanup(
() => verifyTarget(child, targetPath, name),
[() => terminate(child)],
);
try {
await verifyTarget(child, targetPath, name);
} finally {
await terminate(child);
}
}
}, [
async () => {
await closeVerifierResources([
() => client.close(),
() => transport.close(),
]);
} finally {
try {
await client.close();
} finally {
await rm(root, { recursive: true, force: true });
},
]);
}
}
async function verifyTarget(child, targetPath, name) {
const socket = new URL(await inspectorUrl(child));
@@ -197,37 +192,15 @@ async function runMcp(name, arguments_) {
}
async function inspectorUrl(process_) {
return new Promise((resolveUrl, rejectUrl) => {
let captured = "";
const timer = setTimeout(
() =>
finish(new Error("Inspector startup exceeded its 10000ms deadline")),
10_000,
);
const finish = (error, url) => {
clearTimeout(timer);
process_.stderr.off("data", onData);
process_.stderr.off("end", onEnd);
process_.stderr.off("error", onError);
process_.off("error", onError);
if (error !== undefined) rejectUrl(error);
else resolveUrl(url);
};
const onData = (chunk) => {
captured += chunk.toString("utf8");
const match = /Debugger listening on (ws:\/\/[^\s]+)/u.exec(captured);
if (match?.[1] !== undefined) finish(undefined, match[1]);
else if (captured.length > 64 * 1_024)
finish(new Error("Inspector startup output exceeded its bound"));
};
const onEnd = () =>
finish(new Error("Node exited before reporting its Inspector URL"));
const onError = (error) => finish(error);
process_.stderr.on("data", onData);
process_.stderr.once("end", onEnd);
process_.stderr.once("error", onError);
process_.once("error", onError);
});
let captured = "";
for await (const chunk of process_.stderr) {
captured += chunk.toString("utf8");
const match = /Debugger listening on (ws:\/\/[^\s]+)/u.exec(captured);
if (match?.[1] !== undefined) return match[1];
if (captured.length > 64 * 1_024)
throw new Error("Inspector startup output exceeded its bound");
}
throw new Error("Node exited before reporting its Inspector URL");
}
async function runCli(arguments_) {
@@ -263,14 +236,7 @@ async function terminate(process_) {
)
return;
process_.kill("SIGKILL");
clearTimeout(timer);
const killed = await Promise.race([
exited.then(() => true),
new Promise((resolveTimeout) => {
timer = setTimeout(() => resolveTimeout(false), 2_000);
}),
]);
assert.equal(killed, true, "Inspector fixture did not exit after SIGKILL");
await exited;
} finally {
clearTimeout(timer);
}
+6 -14
View File
@@ -1,8 +1,4 @@
#!/usr/bin/env node
import {
closeVerifierResources,
withVerifierCleanup,
} from "../../lib/verifier-cleanup.mjs";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
@@ -55,7 +51,7 @@ const call = async (name, args) => {
const fingerprint = (bytes) => createHash("sha256").update(bytes).digest("hex");
const counts = [];
let finalReport;
await withVerifierCleanup(async () => {
try {
const fixtures = await buildRecoveryFixtures(
root,
process.env.REA_JAVASCRIPT_FIXTURE_TOOLS,
@@ -169,15 +165,11 @@ await withVerifierCleanup(async () => {
results: counts,
verified: true,
};
}, [
async () => {
await closeVerifierResources([
() => client.close(),
() => transport.close(),
]);
await rm(root, { recursive: true, force: true });
},
]);
} finally {
await client.close();
await transport.close();
await rm(root, { recursive: true, force: true });
}
console.log(JSON.stringify(finalReport));
async function assertRecoveredArtifacts(evidence, result, fixture) {
+36 -101
View File
@@ -32,61 +32,50 @@ async function runManagedAppManifestSelfTest(context) {
const members = inspectManagedMembersBytes(sample.bytes, sample.target);
const method = members.methods[0];
assert.ok(method);
const manifest = {
label: "source-owned manifest verifier self-test",
target: {
path: sample.path,
sha256: sample.target.sha256,
mvid: artifact.module?.mvid,
assembly_name: artifact.assembly?.name,
runtime_family: artifact.classification.runtime_family,
managed_architecture: artifact.classification.managed_architecture,
},
methods: [
{
label: "pinned-semantic-slice",
token: method.token,
signature_sha256: method.signature.raw_sha256,
il_size: method.body.il_size,
il_sha256: method.body.il_sha256,
normalized_il_sha256: method.body.normalized_il_sha256,
const summary = await context.verifyManagedAppManifest(
{
label: "source-owned manifest verifier self-test",
target: {
path: sample.path,
sha256: sample.target.sha256,
mvid: artifact.module?.mvid,
assembly_name: artifact.assembly?.name,
runtime_family: artifact.classification.runtime_family,
managed_architecture: artifact.classification.managed_architecture,
},
],
application_graph: {
expected_node_kinds: [
"artifact",
"managed-assembly",
"managed-module",
"managed-type",
"managed-method",
],
feature_traces: [
methods: [
{
label: "trace pinned method by name",
method_token: method.token,
seed: "PinnedSemanticSlice",
match: "exact",
min_matched_seeds: 1,
label: "pinned-semantic-slice",
token: method.token,
signature_sha256: method.signature.raw_sha256,
il_size: method.body.il_size,
il_sha256: method.body.il_sha256,
normalized_il_sha256: method.body.normalized_il_sha256,
},
],
application_graph: {
expected_node_kinds: [
"artifact",
"managed-assembly",
"managed-module",
"managed-type",
"managed-method",
],
feature_traces: [
{
label: "trace pinned method by name",
method_token: method.token,
seed: "PinnedSemanticSlice",
match: "exact",
min_matched_seeds: 1,
},
],
},
},
};
const manifestPath = join(
context.workspace,
"source-owned-managed-app-manifest.json",
);
const summary = await context.verifyManagedAppManifest(
manifest,
manifestPath,
);
const rejectedMismatches = await verifyManifestRejections(
context,
manifest,
manifestPath,
join(context.workspace, "source-owned-managed-app-manifest.json"),
);
return {
verified: true,
rejected_mismatches: rejectedMismatches,
assertions: summary.assertions,
methods: summary.methods.length,
applicationGraph: summary.application_graph ?? null,
@@ -94,60 +83,6 @@ async function runManagedAppManifestSelfTest(context) {
};
}
async function verifyManifestRejections(context, manifest, manifestPath) {
const refusals = [
[
"target sha256",
["target", "sha256"],
"0".repeat(64),
/target sha256 mismatch/u,
],
[
"method token",
["methods", 0, "token"],
"0x0600ffff",
/method 0x0600ffff was not returned/u,
],
[
"signature sha256",
["methods", 0, "signature_sha256"],
"0".repeat(64),
/signature sha256 mismatch/u,
],
["IL size", ["methods", 0, "il_size"], 999, /IL size mismatch/u],
[
"IL sha256",
["methods", 0, "il_sha256"],
"0".repeat(64),
/IL sha256 mismatch/u,
],
[
"normalized IL sha256",
["methods", 0, "normalized_il_sha256"],
"0".repeat(64),
/normalized IL sha256 mismatch/u,
],
];
for (const [label, path, wrong, expected] of refusals) {
const rejected = structuredClone(manifest);
let owner = rejected;
for (const key of path.slice(0, -1)) owner = owner[key];
const key = path.at(-1);
assert.notEqual(
owner[key],
wrong,
`${label} control must differ from the valid manifest`,
);
owner[key] = wrong;
await assert.rejects(
() => context.verifyManagedAppManifest(rejected, manifestPath),
expected,
`Manifest verifier accepted an authored wrong ${label}`,
);
}
return refusals.map(([label]) => label);
}
async function runOptionalManagedAppManifest(context) {
const manifestPath = process.env.REA_MANAGED_APP_MANIFEST_PATH;
if (manifestPath === undefined) return null;
+28 -45
View File
@@ -779,40 +779,20 @@ if [ "$1" = "version" ]; then ${VERSION_BLOCK}; fi`,
});
it("reads a bounded logcat dump with the caller count and buffer", async () => {
const { binary, root } = await writeAdbStub(
(root) => `printf '%s\\n' "$@" >> '${join(root, "argv.log")}'
${shellStub({
logcat:
"08-10 13:00:00.000 I/Tag( 123): first\n08-10 13:00:01.000 I/Tag( 123): second\n",
})(root)}`,
const { binary } = await writeAdbStub(
shellStub({
logcat:
"08-10 13:00:00.000 I/Tag( 123): first\n08-10 13:00:01.000 I/Tag( 123): second\n",
}),
);
const result = await evidenceResult(providerOver(binary), {
operation: "read_adb_logcat",
input: { serial: "emulator-5554", count: 1, buffer: "system", pid: 123 },
input: { serial: "emulator-5554", count: 500, buffer: "main" },
});
expect(
(await readFile(join(root, "argv.log"), "utf8")).trim().split("\n"),
).toEqual([
"version",
"-s",
"emulator-5554",
"shell",
"logcat",
"-d",
"-b",
"system",
"-t",
"1",
"--pid",
"123",
expect(result.lines).toEqual([
"08-10 13:00:00.000 I/Tag( 123): first",
"08-10 13:00:01.000 I/Tag( 123): second",
]);
expect(result).toMatchObject({
count: 1,
buffer: "system",
pid: 123,
coverage: "partial",
lines: ["08-10 13:00:01.000 I/Tag( 123): second"],
});
});
it("reports UTF-8 byte counts in total_bytes for non-ASCII logcat output", async () => {
@@ -1687,15 +1667,13 @@ exit 1`,
operation: "capture_adb_screen",
input: { serial: "emulator-5554", output_directory: output },
});
expect(result).toMatchObject({
width: 1,
height: 1,
bytes: SCREEN_PNG.byteLength,
sha256: createHash("sha256").update(SCREEN_PNG).digest("hex"),
});
expect((await readFile(join(output, "screen.png"))).equals(SCREEN_PNG)).toBe(
true,
);
expect(result).toMatchObject({ width: 1, height: 1 });
expect(
await readFile(join(output, "screen.png")).then(
(bytes) => bytes.subarray(1, 4).toString("latin1") === "PNG",
() => false,
),
).toBe(true);
});
it("stops a screen capture aborted after adb returns but before publication", async () => {
@@ -1911,12 +1889,11 @@ it("reads Android 16 focus declarations from displays when windows omits them",
"mCurrentFocus=Window{abc u0 com.example/.Main}\nmFocusedApp=ActivityRecord{def u0 com.example/.Main t1}\n",
}),
);
const execution = await providerOver(binary).execute({
const result = await evidenceResult(providerOver(binary), {
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
if (!execution.ok) throw execution.error;
expect(execution.value.result).toMatchObject({
expect(result).toMatchObject({
focused_window: "Window{abc u0 com.example/.Main}",
focused_app: "ActivityRecord{def u0 com.example/.Main t1}",
observed_lines: [
@@ -1924,6 +1901,11 @@ it("reads Android 16 focus declarations from displays when windows omits them",
"mFocusedApp=ActivityRecord{def u0 com.example/.Main t1}",
],
});
const execution = await providerOver(binary).execute({
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
if (!execution.ok) throw execution.error;
expect(execution.value.limitations).toContainEqual(
expect.stringContaining("dumpsys window displays"),
);
@@ -1936,15 +1918,16 @@ it("describes missing window focus as unknown after both sections omit declarati
"dumpsys:window:displays": "no focus\\n",
}),
);
const result = await evidenceResult(providerOver(binary), {
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
expect(result).toMatchObject({ focused_window: null, focused_app: null });
const execution = await providerOver(binary).execute({
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
if (!execution.ok) throw execution.error;
expect(execution.value.result).toMatchObject({
focused_window: null,
focused_app: null,
});
expect(execution.value.limitations).toContainEqual(
expect.stringContaining("focus is unknown"),
);
+10
View File
@@ -235,6 +235,16 @@ describe("parseWindowFocusOutput declaration presence", () => {
});
describe("parseDumpsysPackageOutput", () => {
it.each(["userId", "appId"])(
"retains the printed %s package identifier",
(key) => {
expect(
parseDumpsysPackageOutput(
`Packages:\n Package [com.example] (abcdef):\n ${key}=10234\n`,
).userId,
).toBe(10234);
},
);
it("reports unknown requested permissions when the package is null", () => {
expect(
parseDumpsysPackageOutput("Package [com.example] (abcdef):\n pkg=null\n")
+4 -9
View File
@@ -2,6 +2,7 @@ import { expect, it } from "vitest";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { captureJadxFailure } from "./JadxFailure.js";
import { jadxOperationTimeoutGuidance } from "./JadxOperationTimeout.js";
it("names REA_JADX_OPERATION_TIMEOUT_MS when the operation deadline elapses", () => {
const timeout = AbortSignal.abort(new Error("deadline"));
@@ -17,15 +18,9 @@ it("names REA_JADX_OPERATION_TIMEOUT_MS when the operation deadline elapses", ()
session: undefined,
});
const projected = projectAnalysisError(present());
expect(projected.message).toContain("1500 ms");
expect(projected.remediation.action).toContain(
"REA_JADX_OPERATION_TIMEOUT_MS",
);
expect(projected.remediation.action).toContain("update_analysis_settings");
expect(projected.remediation.action).toContain("jadx.operation_timeout_ms");
expect(projected.remediation.action).toContain(
"next operation without restarting REA",
);
const guidance = jadxOperationTimeoutGuidance(1500);
expect(projected.message).toBe(guidance.userMessage);
expect(projected.remediation.action).toBe(guidance.remediationAction);
expect(projected.details).toMatchObject({
limit: {
setting: "jadx.operation_timeout_ms",
+29 -6
View File
@@ -686,20 +686,44 @@ it("projects one locale's strings on request", async () => {
]);
});
it("notes a missing locale table instead of failing", async () => {
const { command, root } = await writeApktoolStub(decodeStub());
const apk = await apkFixture(root);
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true, locale: "fr" },
});
expect(result.strings).toEqual([]);
});
it("notes a missing default strings table without strings", async () => {
const { command, root } = await writeApktoolStub(
decodeStub({ withStrings: false }),
);
const apk = await apkFixture(root);
const execution = await providerOver(command).execute({
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
if (!execution.ok) throw execution.error;
expect(execution.value.result).toMatchObject({ strings: [] });
expect(execution.value.limitations).toContain(
"No default res/values/strings.xml was found in the decoded workspace",
expect(result.strings).toEqual([]);
});
it("reports apktool's own refusal for undecodable targets", async () => {
const { command, root } = await writeApktoolStub(
decodeStub({ failDecode: true }),
);
const apk = await apkFixture(root);
const projected = await refusedBy(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(projected).toMatchObject({
code: "unsupported_target",
details: { path: apk.path },
});
expect(projected.message).toContain(apk.path);
expect(projected.message).not.toContain(command);
expect(projected.message).toContain("Could not decode");
});
it("reports a missing launcher as a capability gap with recovery", async () => {
@@ -1501,7 +1525,6 @@ it.each(["first-selected.apk", "second-selected.apk"])(
details: { path, captured_output: { exit_code: 1 } },
});
expect(projected.message).toContain(path);
expect(projected.message).toContain("Could not decode");
expect(projected.message).not.toContain(command);
} finally {
await provider.close();
+65 -27
View File
@@ -23,6 +23,7 @@ import {
type AnalysisError,
} from "../domain/analysisErrorBase.js";
import type { Result } from "../domain/result.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { err, ok } from "../domain/result.js";
const provider = { id: "fixture", name: "Fixture", version: "1" };
@@ -136,6 +137,52 @@ describe("analysis context queries: navigation", () => {
);
});
it.each<JsonValue>([
null,
{},
{ found: false, procedure: null },
{ query_address: "0x401000", found: true, procedure: null },
])(
"keeps malformed containing-procedure output unknown: %j",
async (value) => {
const analysis: AnalysisOperationPort = {
execute: async (operation) =>
ok(
createAnalysisExecution(
operation === "resolve_containing_procedure"
? value
: operation === "current_document"
? "fixture"
: "0x401000",
provider,
),
),
};
expect(await getNavigationContext(analysis, {})).toMatchObject({
ok: false,
error: {
_tag: "AnalysisOutputError",
operation: "resolve_containing_procedure",
},
});
},
);
it("does not infer procedure absence from a provider error message", async () => {
const failure = new AnalysisProtocolError(
"not in a procedure: malformed reply",
);
const analysis: AnalysisOperationPort = {
execute: async (operation) =>
operation === "resolve_containing_procedure"
? err(failure)
: ok(createAnalysisExecution("0x401000", provider)),
};
expect(
await getNavigationContext(analysis, { document: "fixture" }),
).toEqual(err(failure));
});
it("uses an explicit document without passing it to current_document and preserves procedure failures", async () => {
const calls: string[] = [];
const failure = new AnalysisProtocolError("provider response malformed");
@@ -162,14 +209,6 @@ describe("navigation procedure admission", () => {
it.each([
{ condition: "missing result", value: null },
{ condition: "missing discriminator", value: {} },
{
condition: "missing absence reason",
value: { found: false, procedure: null },
},
{
condition: "found procedure is null",
value: { query_address: "0x401000", found: true, procedure: null },
},
{
condition: "missing query identity",
value: { found: false, procedure: null, reason: "not_in_procedure" },
@@ -207,25 +246,24 @@ describe("navigation procedure admission", () => {
},
);
it.each([
"Provider failed: no procedure exists",
"Provider failed: not in a procedure",
"not in a procedure: malformed reply",
])("preserves the original provider failure: %s", async (message) => {
const failure = new AnalysisProtocolError(message);
const result = await getNavigationContext(
{
execute: async (operation) =>
operation === "current_address"
? ok(createAnalysisExecution("0x401000", provider))
: err(failure),
},
{ document: "fixture" },
);
expect(result).toEqual(err(failure));
if (result.ok) throw new Error("expected original provider failure");
expect(result.error).toBe(failure);
});
it.each(["no procedure exists", "not in a procedure"])(
"preserves a provider failure containing %s",
async (message) => {
const failure = new AnalysisProtocolError(`Provider failed: ${message}`);
const result = await getNavigationContext(
{
execute: async (operation) =>
operation === "current_address"
? ok(createAnalysisExecution("0x401000", provider))
: err(failure),
},
{ document: "fixture" },
);
expect(result).toEqual(err(failure));
if (result.ok) throw new Error("expected original provider failure");
expect(result.error).toBe(failure);
},
);
it("returns a validated containing procedure name", async () => {
const result = await getNavigationContext(
-3
View File
@@ -97,7 +97,6 @@ import {
isDfuSeFile,
} from "../domain/firmware/dfuse.js";
import { hasFitImagesNode } from "../domain/firmware/fitImage.js";
import { hasMftFileRecordHeader } from "../domain/windows/ntfsMft.js";
import {
locateEspFlashLayout,
type EspFlashSelection,
@@ -817,7 +816,6 @@ const detectArtifactFormat = async (
});
if (esp === "esp-image") return "esp-image";
if (hasFitImagesNode(magic)) return "fit";
if (hasMftFileRecordHeader(magic)) return "ntfs-mft";
// Binary plists, including keyed archives, often use other extensions.
if (hasBinaryPlistSignature(magic)) return "plist";
const named = namedArtifactFormat(lower);
@@ -901,7 +899,6 @@ const isArchiveFormat = (
"dfuse",
"esp-flash-image",
"fit",
"ntfs-mft",
].includes(format);
/** Whether the leading bytes begin any executable header family, valid or not. */
+1 -1
View File
@@ -389,7 +389,7 @@ const providerSettingSelections = (
];
/** Render what the registration comparison established without inferring its effective environment. */
const registrationEnvironmentDetail = (
export const registrationEnvironmentDetail = (
client: string,
{
unrecorded,
@@ -4,7 +4,12 @@ import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { createDoctorHostFixture as host } from "./Doctor.fixture.js";
import { runDoctor, systemDoctorHost, type DoctorHost } from "./Doctor.js";
import {
registrationEnvironmentDetail,
runDoctor,
systemDoctorHost,
type DoctorHost,
} from "./Doctor.js";
const inspection = (
id: string,
@@ -181,12 +186,11 @@ it("states each comparison result without a claim it did not establish", async (
classification: "unknown",
details: { effective_environment: "unknown" },
});
expect(check?.detail).toContain(
"what the server cursor starts receives is unknown",
expect(check?.detail).toBe(
registrationEnvironmentDetail("cursor", {
unrecorded: [],
differing: ["JAVA_HOME"],
unresolved: ["GHIDRA_INSTALL_DIR"],
}),
);
expect(check?.detail).toContain("It records other values for JAVA_HOME.");
expect(check?.detail).toContain(
"REA could not resolve its GHIDRA_INSTALL_DIR.",
);
expect(check?.detail).not.toContain("does not record");
});
@@ -18,10 +18,7 @@ import {
FINALIZED_PROCESS_CAPTURE_EXAMPLE,
} from "../domain/process/processCapture.fixture.js";
import { PROCESS_PROVIDER } from "../domain/process/processEvidenceProvider.js";
import {
parseProcessCapture,
safeParseProcessCapture,
} from "../domain/process/processCaptureParsing.js";
import { parseProcessCapture } from "../domain/process/processCaptureParsing.js";
import {
digestProcessCommitment,
processScenarioSchema,
@@ -255,10 +252,6 @@ describe("reconstruction obligation candidates", () => {
},
},
});
const invalid = safeParseProcessCapture(evidence.normalized_result);
expect(invalid.success).toBe(false);
if (invalid.success) throw new Error("Expected invalid capture fixture");
expect(invalid.error.issues).toHaveLength(2);
const generated = deriveCandidates(createEvidenceBundle([evidence]), []);
expect(generated.limitations).toHaveLength(1);
@@ -266,7 +259,6 @@ describe("reconstruction obligation candidates", () => {
expect(generated.limitations[0]).toMatch(
/Process Evidence.*could not generate candidates.*manifest\.completed_at/iu,
);
expect(generated.limitations[0]).toContain("(1 more issues)");
});
});
@@ -9,7 +9,6 @@ import {
proofEvidence,
request,
reviewedObligation,
readyReviewedLedgerRequest,
} from "./ReconstructionObligationLedger.fixture.js";
import { createEvidence } from "../domain/evidence.js";
import type { ReviewedReconstructionObligation } from "../domain/reconstructionObligationLedgerSchemas.js";
@@ -283,23 +282,7 @@ describe("reconstruction obligation ledger", () => {
});
describe("reconstruction obligation ledger fail-closed behavior", () => {
it("requires a parser, schema and domain type for a reviewed parser boundary", () => {
const input = readyReviewedLedgerRequest();
input.reviewed_obligations[0]!.requires_parser_type = true;
const missing = build(input);
expect(missing.status).toBe("open");
expect(missing.obligations[0]?.diagnostics.map(({ code }) => code)).toEqual(
["missing-parser-type"],
);
input.manifest.bindings[0]!.parser_type = {
parser: "parseRequest",
schema: "requestSchema",
domain_type: "Request",
};
expect(build(input).status).toBe("ready");
});
it("fails closed on ambiguous ownership and contradiction", () => {
it("fails closed on parser gaps, ambiguous ownership, and contradiction", () => {
const original = createEvidence(
undefined,
{ id: "external", name: "External protocol observer", version: "1" },
+1 -1
View File
@@ -481,7 +481,7 @@ describe("setup plan application", () => {
expect(host.doctorCalls).toBe(1);
});
it("completes an empty interactive selection without changes", async () => {
it("treats an empty interactive selection as cancellation", async () => {
const host = new FakeSetupHost();
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
@@ -213,6 +213,8 @@ it.each(["pending", "already recovered"] as const)(
expect(source.cleanupIncomplete).toBe(false);
expect(first?.cleanupIncomplete).toBe(true);
expect(second?.cleanupIncomplete).toBe(true);
expect(first?.cleanupResources).toContain("/delegated");
expect(second?.cleanupResources).toContain("/delegated");
expect(first?.cleanupResources).toEqual(["/delegated"]);
expect(second?.cleanupResources).toEqual(["/delegated"]);
expect(await owner.close()).toBeUndefined();
@@ -40,7 +40,7 @@ it.each([
});
},
);
it("rejects source text changed across the artifact port before acquiring the resolver", async () => {
it("rejects source text changed across the artifact port while retaining the original digest", async () => {
const artifacts = {
...webModuleArtifactsFixture("import(name)"),
source: "import(other)",
@@ -304,6 +304,22 @@ it("honors cancellation both before acquisition and across decoder completion",
].map((path) => ({ kind: "artifact-path", path })),
});
const malformedDecoder: WebSourceMapPort = {
trace: async (input, options) => {
const decoded = await decoder.trace(input, options);
return decoded.ok
? ok({ ...decoded.value, source_map_sha256: "b".repeat(64) })
: decoded;
},
};
const malformed = await new WebSourceLocationService(
{ load: () => Promise.resolve(ok(webSourceLocationFixture())) },
malformedDecoder,
).trace(args);
expect(malformed.ok).toBe(false);
if (!malformed.ok)
expect(malformed.error).toBeInstanceOf(AnalysisOutputError);
const invalidController = new AbortController();
const malformedLate = await new WebSourceLocationService(
{ load: () => Promise.resolve(ok(webSourceLocationFixture())) },
+34
View File
@@ -725,6 +725,40 @@ describe("workflow residual question retention", () => {
);
});
it.each([
["spaces", " Which boundary? "],
["line feed and tab", "\nWhich boundary?\t"],
["byte order mark", "\uFEFFWhich boundary?\uFEFF"],
])(
"normalizes edge whitespace from %s without changing workflow Evidence",
(_, question) => {
const records = new InvestigationRecords();
const result = { residual_unknowns: [question] };
const evidence = createWorkflowEvidence({
target: undefined,
operation: "inspect_native_api",
parameters: {},
result,
upstreamProfile: undefined,
upstreamLimitations: [],
});
expect(records.recordEvidence(evidence).ok).toBe(true);
expect(
recordWorkflowUnknowns({
name: "inspect_native_api",
result,
evidenceId: evidence.evidence_id,
recordUnknown: (unknown) => records.recordUnknown(unknown),
}).ok,
).toBe(true);
expect(records.listUnknowns()).toMatchObject([
{ question: `Which boundary? (Evidence ${evidence.evidence_id})` },
]);
expect(evidence.normalized_result).toEqual(result);
},
);
it("rejects questions that are empty after whitespace normalization", () => {
const records = new InvestigationRecords();
const result = { residual_unknowns: [" \n\t "] };
@@ -36,7 +36,7 @@ import {
isSourceEvidenceRetainable,
} from "./AnalysisSnapshotCache.js";
import { EvidenceLedger } from "../investigation/EvidenceLedger.js";
import { nativeCallGraphSchema } from "../../domain/native/nativeCallGraph.js";
import { enhancedOutputSchemas } from "../../contracts/toolOutputSchemaGroups.js";
it.each(["provider", "workflow"] as const)(
"owns nested %s execution data before returning control to callers",
@@ -1270,13 +1270,12 @@ it.each([
evidenceProfile: revisedProfile,
}),
).toBeUndefined();
if (operation === "get_call_graph") {
expect(nativeCallGraphSchema.safeParse(oldResult).success).toBe(false);
if (operation === "get_call_graph")
expect(
nativeCallGraphSchema.safeParse({ graph: oldResult, limitations: [] })
.success,
).toBe(true);
}
enhancedOutputSchemas.get_call_graph.safeParse({
normalized_result: oldResult,
}).success,
).toBe(false);
},
);
@@ -18,8 +18,6 @@ const identity: ProviderIdentity = {
it("retains both child close failures when a resource owner rejects", async () => {
const attempts: string[] = [];
const artifactCause = new Error("artifact close rejected");
const nativeCause = new Error("native close rejected");
const artifact: AnalysisClient = {
execute: async () => ({
ok: true,
@@ -27,7 +25,7 @@ it("retains both child close failures when a resource owner rejects", async () =
}),
close: async () => {
attempts.push("artifact");
throw artifactCause;
throw new Error("artifact close rejected");
},
};
const native: AnalysisClient = {
@@ -37,20 +35,14 @@ it("retains both child close failures when a resource owner rejects", async () =
}),
close: async () => {
attempts.push("native");
throw nativeCause;
throw new Error("native close rejected");
},
};
const closed = await combineBundleResourceClients(artifact, native).close();
expect(attempts.sort()).toEqual(["artifact", "native"]);
expect(closed.ok).toBe(false);
if (closed.ok) throw new Error("Expected both child close failures");
expect(closed.error.cleanupIncomplete).toBe(true);
expect(closed.error.cause).toBeInstanceOf(AggregateError);
const failures = (closed.error.cause as AggregateError).errors;
expect(failures).toHaveLength(2);
expect((failures[0] as Error).cause).toBe(artifactCause);
expect((failures[1] as Error).cause).toBe(nativeCause);
if (!closed.ok) expect(closed.error.cleanupIncomplete).toBe(true);
});
it("reports the first mixed child failure before awaiting the other owner and retries both", async () => {
@@ -135,7 +135,6 @@ const TARGETS: TargetFixtures = {
espFlash: { selected: true, flashOffset: null, partitionTableOffset: null },
},
fit: { ...archive, format: "fit" },
"ntfs-mft": { ...archive, format: "ntfs-mft" },
plist: { ...artifact, format: "plist" },
javascript: { ...artifact, format: "javascript" },
"source-map": { ...artifact, format: "source-map" },
@@ -1,4 +1,3 @@
import { parseEvidence } from "../../domain/evidence.js";
import { expect, it } from "vitest";
import { AppleIpsService } from "./AppleIpsService.js";
import { APPLE_IPS_PROVIDER } from "../../domain/investigationProviders.js";
@@ -233,13 +232,12 @@ it.each([
},
);
it("retains a completed observation when the caller cancels as the provider completes", async () => {
it("discards an observation if the caller cancels while the provider completes", async () => {
const controller = new AbortController();
const reason = { request: "late AppleIps cancellation" };
const service = new AppleIpsService({
identity,
inspect: () => {
controller.abort(reason);
controller.abort();
return Promise.resolve(ok(report));
},
});
@@ -247,13 +245,8 @@ it("retains a completed observation when the caller cancels as the provider comp
{ path: report.artifact.path },
{ signal: controller.signal },
);
if (result.ok) throw new Error("Expected late cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
operation: "inspect_apple_ips",
normalized_result: report,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError);
});
it.each([
@@ -1,4 +1,3 @@
import { parseEvidence } from "../../domain/evidence.js";
import { expect, it } from "vitest";
import { DevicetreeService } from "./DevicetreeService.js";
import { DEVICETREE_PROVIDER } from "../../domain/investigationProviders.js";
@@ -73,13 +72,12 @@ it("validates caller paths before invoking the provider", async () => {
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisInputError);
expect(calls).toBe(0);
});
it("retains a completed observation when the caller cancels as the provider completes", async () => {
it("rejects cancellation at provider completion", async () => {
const controller = new AbortController();
const reason = { request: "late Devicetree cancellation" };
const service = new DevicetreeService({
identity: DEVICETREE_PROVIDER,
inspect: () => {
controller.abort(reason);
controller.abort();
return Promise.resolve(ok(report()));
},
});
@@ -87,13 +85,8 @@ it("retains a completed observation when the caller cancels as the provider comp
{ path: "/board.dtb" },
{ signal: controller.signal },
);
if (result.ok) throw new Error("Expected late cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
operation: "inspect_devicetree",
normalized_result: report(),
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError);
});
it("rejects provider coverage and source-range inconsistencies", async () => {
for (const change of [
@@ -1,4 +1,3 @@
import { parseEvidence } from "../../domain/evidence.js";
import { expect, it } from "vitest";
import { ElfNotesService } from "./ElfNotesService.js";
import { ELF_NOTES_PROVIDER } from "../../domain/investigationProviders.js";
@@ -107,13 +106,12 @@ it("retains an examined empty table and rejects an uncounted table", async () =>
}
});
it("retains a completed observation when the caller cancels as the provider completes", async () => {
it("discards an observation if the caller cancels while the provider completes", async () => {
const controller = new AbortController();
const reason = { request: "late ElfNotes cancellation" };
const service = new ElfNotesService({
identity,
inspect: () => {
controller.abort(reason);
controller.abort();
return Promise.resolve(ok(report));
},
});
@@ -121,11 +119,6 @@ it("retains a completed observation when the caller cancels as the provider comp
{ path: report.artifact.path },
{ signal: controller.signal },
);
if (result.ok) throw new Error("Expected late cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
operation: "inspect_elf_notes",
normalized_result: report,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError);
});
@@ -1,4 +1,3 @@
import { parseEvidence } from "../../domain/evidence.js";
import { expect, it } from "vitest";
import { ElfUnwindRangesService } from "./ElfUnwindRangesService.js";
import { ELF_UNWIND_RANGES_PROVIDER } from "../../domain/investigationProviders.js";
@@ -407,13 +406,12 @@ it("rejects a report that fails the output schema", async () => {
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
});
it("retains a completed observation when the caller cancels as the provider completes", async () => {
it("discards an observation if the caller cancels while the provider completes", async () => {
const controller = new AbortController();
const reason = { request: "late ElfUnwindRanges cancellation" };
const service = new ElfUnwindRangesService({
identity,
inspect: () => {
controller.abort(reason);
controller.abort();
return Promise.resolve(ok(report));
},
});
@@ -421,11 +419,6 @@ it("retains a completed observation when the caller cancels as the provider comp
{ path: report.artifact.path },
{ signal: controller.signal },
);
if (result.ok) throw new Error("Expected late cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
operation: "inspect_elf_unwind_ranges",
normalized_result: report,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError);
});
@@ -1,4 +1,3 @@
import { parseEvidence } from "../../domain/evidence.js";
import { expect, it } from "vitest";
import { NativeInitializersService } from "./NativeInitializersService.js";
import { NATIVE_INITIALIZERS_PROVIDER } from "../../domain/investigationProviders.js";
@@ -114,13 +113,12 @@ it("rejects a slot offset outside the artifact", async () => {
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
});
it("retains a completed observation when the caller cancels as the provider completes", async () => {
it("discards an observation if the caller cancels while the provider completes", async () => {
const controller = new AbortController();
const reason = { request: "late NativeInitializers cancellation" };
const service = new NativeInitializersService({
identity,
inspect: () => {
controller.abort(reason);
controller.abort();
return Promise.resolve(ok(report));
},
});
@@ -128,11 +126,6 @@ it("retains a completed observation when the caller cancels as the provider comp
{ path: report.artifact.path },
{ signal: controller.signal },
);
if (result.ok) throw new Error("Expected late cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
operation: "inspect_native_initializers",
normalized_result: report,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError);
});
@@ -1,4 +1,3 @@
import { parseEvidence } from "../../domain/evidence.js";
import { createHash } from "node:crypto";
import { inspectPeCertificatesInputSchema } from "../../domain/native/peCertificates.js";
import { peCertificateFixture } from "../../native/pe/PeCertificates.fixture.js";
@@ -50,13 +49,12 @@ it("rejects a relative path and extra arguments before reading", async () => {
expect(calls).toBe(0);
});
it("retains a completed observation when the caller cancels as the provider completes", async () => {
it("discards an observation if the caller cancels while the provider completes", async () => {
const controller = new AbortController();
const reason = { request: "late PeCertificates cancellation" };
const service = new PeCertificatesService({
identity,
inspect: () => {
controller.abort(reason);
controller.abort();
return Promise.resolve(ok(report));
},
});
@@ -64,13 +62,8 @@ it("retains a completed observation when the caller cancels as the provider comp
{ path: report.artifact.path },
{ signal: controller.signal },
);
if (result.ok) throw new Error("Expected late cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
operation: "inspect_pe_certificates",
normalized_result: report,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError);
});
it("retains complete certificate provenance from the production parser", async () => {
@@ -1,4 +1,3 @@
import { parseEvidence } from "../../domain/evidence.js";
import { expect, it } from "vitest";
import { PeImportsService } from "./PeImportsService.js";
import { PE_IMPORTS_PROVIDER } from "../../domain/investigationProviders.js";
@@ -117,13 +116,12 @@ it.each(["import_directory", "delay_import_directory"] as const)(
},
);
it("retains a completed observation when the caller cancels as the provider completes", async () => {
it("discards an observation if the caller cancels while the provider completes", async () => {
const controller = new AbortController();
const reason = { request: "late PeImports cancellation" };
const service = new PeImportsService({
identity,
inspect: () => {
controller.abort(reason);
controller.abort();
return Promise.resolve(ok(report));
},
});
@@ -131,13 +129,8 @@ it("retains a completed observation when the caller cancels as the provider comp
{ path: report.artifact.path },
{ signal: controller.signal },
);
if (result.ok) throw new Error("Expected late cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
operation: "inspect_pe_imports",
normalized_result: report,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError);
});
it("publishes every consumed directory span as its own Evidence location", async () => {
@@ -1,4 +1,3 @@
import { parseEvidence } from "../../domain/evidence.js";
import { expect, it } from "vitest";
import { WindowsShortcutService } from "./WindowsShortcutService.js";
import { WINDOWS_SHORTCUT_PROVIDER } from "../../domain/investigationProviders.js";
@@ -144,13 +143,12 @@ it("rejects complete coverage that still lists issues", async () => {
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
});
it("retains a completed observation when the caller cancels as the provider completes", async () => {
it("discards an observation if the caller cancels while the provider completes", async () => {
const controller = new AbortController();
const reason = { request: "late WindowsShortcut cancellation" };
const service = new WindowsShortcutService({
identity,
inspect: () => {
controller.abort(reason);
controller.abort();
return Promise.resolve(ok(report));
},
});
@@ -158,11 +156,6 @@ it("retains a completed observation when the caller cancels as the provider comp
{ path: report.artifact.path },
{ signal: controller.signal },
);
if (result.ok) throw new Error("Expected late cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
operation: "inspect_windows_shortcut",
normalized_result: report,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError);
});
@@ -1010,7 +1010,7 @@ describe("decompile_all partial results", () => {
if (!result.ok)
expect(result.error.userMessage).toContain("before address start");
expect(calls).toHaveLength(0);
await expect(stat(directory())).rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(directory())).rejects.toThrow();
});
});
@@ -910,7 +910,6 @@ describe("inspectDesktopApplicationState database sidecars", () => {
it("reports cancellation during the last database read instead of Evidence", async () => {
const controller = new AbortController();
const reason = { stage: "last database read" };
let reads = 0;
const folder = await writeCaptureFolder(
extended({
@@ -926,7 +925,7 @@ describe("inspectDesktopApplicationState database sidecars", () => {
...readers,
sqlite: async (database, signal) => {
reads += 1;
if (reads === 3) controller.abort(reason);
if (reads === 3) controller.abort(new Error("stopped"));
return readers.sqlite(database, signal);
},
},
@@ -935,9 +934,6 @@ describe("inspectDesktopApplicationState database sidecars", () => {
expect(reads).toBe(3);
expect(result).toBeInstanceOf(AnalysisCancelledError);
expect(result instanceof AnalysisCancelledError && result.cause).toBe(
reason,
);
});
});
@@ -321,7 +321,6 @@ it.each([
expect(result.error.cleanupResources).toContain(join(bundle, suffix));
if (cancel) {
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
expect(result.error.partialObservation).toMatchObject({
kind: "partial-task-measurement",
solves: [{}, {}, {}],
@@ -57,14 +57,11 @@ it("keeps the place of a call cancelled while it waits, so the one after it stil
return ok("last");
});
await settled();
const reason = { caller: "the client went away" };
caller.abort(reason);
const cancellation = await cancelled;
expect(cancellation).toMatchObject({
caller.abort(new Error("the client went away"));
expect(await cancelled).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError", operation: "cancelled" },
});
expect(!cancellation.ok && cancellation.error.cause).toBe(reason);
await settled();
expect(log).toEqual(["running"]);
running.end();
@@ -81,9 +81,9 @@ it("waits for an interrupted reader before removing its folder", async () => {
it("preserves a completed view when cancellation arrives during successful cleanup", async () => {
const controller = new AbortController();
const reason = { stage: "reader final cleanup" };
const owner = new DesktopRemoteTaskReaders(async (argv) => {
if (argv[3] === PYTHON_READER_CLEANUP) controller.abort(reason);
if (argv[3] === PYTHON_READER_CLEANUP)
controller.abort(new Error("cancelled"));
return success;
});
const failure = await owner
@@ -91,7 +91,6 @@ it("preserves a completed view when cancellation arrives during successful clean
.catch((error: unknown) => error);
if (!(failure instanceof AnalysisCancelledError))
throw new Error("expected cancellation");
expect(failure.cause).toBe(reason);
expect(failure.diagnostics).toMatchObject({
completed_reader_views: { note: { view: { value: 7 } } },
});
@@ -100,13 +99,12 @@ it("preserves a completed view when cancellation arrives during successful clean
it("keeps cancellation and a completed view when final cleanup also fails", async () => {
const controller = new AbortController();
const reason = { stage: "reader final cleanup" };
let fail = true;
const owner = new DesktopRemoteTaskReaders(async (argv) => {
if (argv[3] !== PYTHON_READER_CLEANUP) return success;
if (fail) {
fail = false;
controller.abort(reason);
controller.abort(new Error("cancelled"));
return { ...success, exit_code: 1, stderr: "denied" };
}
return success;
@@ -117,7 +115,6 @@ it("keeps cancellation and a completed view when final cleanup also fails", asyn
if (!(failure instanceof ProviderCleanupError))
throw new Error("expected cleanup failure");
expect(failure.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(failure.cleanupPrimary?.cause).toBe(reason);
expect(failure.diagnostics).toMatchObject({
completed_reader_views: { note: { view: { value: 7 } } },
});
@@ -736,9 +736,7 @@ describe("buildEnvironment dry-run package tarball", () => {
code: "invalid_request",
details: { issues: [{ path: ["package_tarball"] }] },
});
await expect(readdir(input.directory!)).rejects.toMatchObject({
code: "ENOENT",
});
expect(await readdir(root)).not.toContain("bundle");
} finally {
read.mockRestore();
}
Loaded 100 of 395 files, more files were not shown because too many files have changed in this diff. Show more